feat(ui): complete chat interface overhaul

- Replaced all basic Tailwind components with Premium Glassmorphism design
- Implemented neon accents and custom scrollbars
- Added typing indicators and file upload UI polish
- Integrated Chat Page with the new Layout system
This commit is contained in:
senke 2026-01-11 03:20:52 +01:00
parent 62898a8916
commit e127a6a48c
5 changed files with 328 additions and 234 deletions

View file

@ -1,15 +1,18 @@
import React, { useState, useCallback, useRef, useEffect, lazy, Suspense } from 'react'; import React, { useState, useCallback, useRef, useEffect, lazy, Suspense } from 'react';
import { Send, Smile, Paperclip, X, Image as ImageIcon, File } from 'lucide-react'; import { Send, Smile, Paperclip, X, Image as ImageIcon, File, Mic } from 'lucide-react';
import { useChat } from '../hooks/useChat'; import { useChat } from '../hooks/useChat';
import { useChatStore } from '../store/chatStore'; import { useChatStore } from '../store/chatStore';
// PERF: Lazy load EmojiPicker (composant volumineux ~200KB)
const EmojiPicker = lazy(() => import('emoji-picker-react').then(module => ({ default: module.default })));
import { Theme } from 'emoji-picker-react'; import { Theme } from 'emoji-picker-react';
import { useDropzone } from 'react-dropzone'; import { useDropzone } from 'react-dropzone';
import { apiClient } from '@/services/api/client'; import { apiClient } from '@/services/api/client';
import { MessageAttachment } from '../types'; import { MessageAttachment } from '../types';
import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
// Lazy load
const EmojiPicker = lazy(() => import('emoji-picker-react').then(module => ({ default: module.default })));
export const ChatInput: React.FC = () => { export const ChatInput: React.FC = () => {
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
@ -27,10 +30,7 @@ export const ChatInput: React.FC = () => {
setMessage(''); setMessage('');
setAttachments([]); setAttachments([]);
// Stop typing indicator immediately if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
setTyping(false); setTyping(false);
} }
}; };
@ -43,17 +43,14 @@ export const ChatInput: React.FC = () => {
const uploadPromises = acceptedFiles.map(async (file) => { const uploadPromises = acceptedFiles.map(async (file) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
// Use existing upload endpoint
const response = await apiClient.post('/uploads', formData, { const response = await apiClient.post('/uploads', formData, {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' }
}); });
const data = response.data; const data = response.data;
return { return {
file_name: file.name, file_name: file.name,
file_type: file.type, file_type: file.type,
file_url: data.url, // Assuming backend returns { url: "..." } file_url: data.url,
file_size: file.size, file_size: file.size,
} as MessageAttachment; } as MessageAttachment;
}); });
@ -62,8 +59,7 @@ export const ChatInput: React.FC = () => {
setAttachments((prev) => [...prev, ...newAttachments]); setAttachments((prev) => [...prev, ...newAttachments]);
} catch (error) { } catch (error) {
logger.error('Failed to upload files', { logger.error('Failed to upload files', {
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error)
stack: error instanceof Error ? error.stack : undefined,
}); });
} finally { } finally {
setIsUploading(false); setIsUploading(false);
@ -72,7 +68,7 @@ export const ChatInput: React.FC = () => {
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop, onDrop,
noClick: true, // We want custom click on button noClick: true,
}); });
const handleEmojiClick = (emojiData: { emoji: string }) => { const handleEmojiClick = (emojiData: { emoji: string }) => {
@ -80,60 +76,52 @@ export const ChatInput: React.FC = () => {
setShowEmojiPicker(false); setShowEmojiPicker(false);
}; };
const handleFileButtonClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
onDrop(Array.from(e.target.files));
}
};
const removeAttachment = (index: number) => { const removeAttachment = (index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index)); setAttachments((prev) => prev.filter((_, i) => i !== index));
}; };
// Typing indicator logic
useEffect(() => { useEffect(() => {
if (message.length > 0) { if (message.length > 0) {
setTyping(true); setTyping(true);
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => { typingTimeoutRef.current = setTimeout(() => {
setTyping(false); setTyping(false);
}, 3000); // Stop typing after 3 seconds of inactivity }, 3000);
} else { } else {
setTyping(false); setTyping(false);
} }
}, [message, setTyping]); }, [message, setTyping]);
return ( return (
<div {...getRootProps()} className="border-t bg-gray-50"> <div {...getRootProps()} className="relative">
<input <input {...getInputProps()} ref={fileInputRef} className="hidden" />
{...getInputProps()}
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
/>
{/* File Previews */} {/* Upload Overlay */}
{attachments.length > 0 && ( {isDragActive && (
<div className="flex flex-wrap gap-2 p-2 px-4 border-b bg-white"> <div className="absolute bottom-full left-0 right-0 h-48 z-50 bg-kodo-cyan/10 backdrop-blur-md flex items-center justify-center border-t-2 border-kodo-cyan border-dashed rounded-t-2xl animate-fadeIn">
{attachments.map((att, i) => ( <div className="text-center">
<div key={i} className="relative group flex items-center gap-2 p-1.5 bg-gray-100 rounded-md border text-xs"> <div className="w-12 h-12 rounded-full bg-kodo-cyan/20 flex items-center justify-center mx-auto mb-2 animate-bounce">
{att.file_type.startsWith('image') ? ( <Paperclip className="w-6 h-6 text-kodo-cyan" />
<ImageIcon size={14} className="text-blue-500" /> </div>
) : ( <p className="text-kodo-cyan font-mono uppercase tracking-widest text-sm">Initiate Data Transfer</p>
<File size={14} className="text-gray-500" /> </div>
</div>
)} )}
<span className="truncate max-w-[100px]">{att.file_name}</span>
{/* Attachments Preview */}
{attachments.length > 0 && (
<div className="absolute bottom-full left-0 right-0 p-3 bg-kodo-void/90 backdrop-blur-xl border-t border-white/10 flex gap-2 overflow-x-auto">
{attachments.map((att, i) => (
<div key={i} className="relative group flex items-center gap-2 p-2 bg-white/5 rounded-lg border border-white/10 text-xs text-white min-w-[150px]">
{att.file_type.startsWith('image') ? (
<ImageIcon size={14} className="text-kodo-cyan" />
) : (
<File size={14} className="text-kodo-secondary" />
)}
<span className="truncate flex-1">{att.file_name}</span>
<button <button
onClick={() => removeAttachment(i)} onClick={() => removeAttachment(i)}
className="p-0.5 hover:bg-gray-200 rounded-full" className="p-1 hover:bg-white/10 rounded-full text-kodo-red opacity-0 group-hover:opacity-100 transition-opacity"
> >
<X size={12} /> <X size={12} />
</button> </button>
@ -142,46 +130,43 @@ export const ChatInput: React.FC = () => {
</div> </div>
)} )}
{isDragActive && ( <form onSubmit={handleSubmit} className="flex items-center gap-2">
<div className="absolute inset-0 z-50 bg-blue-500/10 flex items-center justify-center border-2 border-dashed border-blue-500 pointer-events-none">
<p className="text-blue-600 font-semibold">Déposez vos fichiers ici</p>
</div>
)}
<form
onSubmit={handleSubmit}
className="flex items-center gap-2 p-3"
>
<div className="flex gap-1"> <div className="flex gap-1">
<button <Button
type="button" type="button"
className="p-2 text-gray-500 hover:bg-gray-200 rounded-lg transition-colors" variant="ghost"
onClick={handleFileButtonClick} size="icon"
className="text-kodo-secondary hover:text-kodo-cyan hover:bg-white/5"
onClick={() => fileInputRef.current?.click()}
> >
<Paperclip size={20} /> <Paperclip size={20} />
</button> </Button>
<div className="relative"> <div className="relative">
<button <Button
type="button" type="button"
variant="ghost"
size="icon"
className={cn( className={cn(
"p-2 text-gray-500 hover:bg-gray-200 rounded-lg transition-colors", "text-kodo-secondary hover:text-kodo-cyan hover:bg-white/5",
showEmojiPicker && "bg-gray-200 text-blue-600" showEmojiPicker && "text-kodo-cyan bg-white/5"
)} )}
onClick={() => setShowEmojiPicker(!showEmojiPicker)} onClick={() => setShowEmojiPicker(!showEmojiPicker)}
> >
<Smile size={20} /> <Smile size={20} />
</button> </Button>
{showEmojiPicker && ( {showEmojiPicker && (
<div className="absolute bottom-full left-0 mb-2 z-50"> <div className="absolute bottom-full left-0 mb-4 z-50 animate-scaleIn origin-bottom-left">
<div className="fixed inset-0" onClick={() => setShowEmojiPicker(false)} /> <div className="fixed inset-0" onClick={() => setShowEmojiPicker(false)} />
<div className="relative"> <div className="relative shadow-2xl rounded-xl overflow-hidden border border-white/10">
<Suspense fallback={<div className="w-[352px] h-[435px] bg-white rounded-lg flex items-center justify-center"><LoadingSpinner size="sm" /></div>}> <Suspense fallback={<div className="w-[350px] h-[450px] bg-kodo-ink flex items-center justify-center"><LoadingSpinner /></div>}>
<EmojiPicker <EmojiPicker
onEmojiClick={handleEmojiClick} onEmojiClick={handleEmojiClick}
theme={Theme.LIGHT} theme={Theme.DARK}
lazyLoadEmojis={true} lazyLoadEmojis={true}
width={350}
height={450}
/> />
</Suspense> </Suspense>
</div> </div>
@ -190,32 +175,39 @@ export const ChatInput: React.FC = () => {
</div> </div>
</div> </div>
<div className="flex-1 relative">
<input <input
type="text" type="text"
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
placeholder="Écrire un message..." placeholder="Broadcast message..."
className="flex-1 p-2 bg-white border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all" className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder:text-kodo-secondary/50 focus:outline-none focus:border-kodo-cyan/50 focus:ring-1 focus:ring-kodo-cyan/50 transition-all font-mono text-sm"
disabled={!currentConversationId || isUploading} disabled={!currentConversationId || isUploading}
/> />
{message.length === 0 && !isUploading && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Mic className="w-4 h-4 text-kodo-secondary/30 hover:text-kodo-cyan cursor-pointer transition-colors" />
</div>
)}
</div>
<button <Button
type="submit" type="submit"
className="p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors" variant="default"
size="icon"
className={cn(
"rounded-xl transition-all duration-300",
message.trim() || attachments.length > 0 ? "bg-kodo-cyan text-kodo-void hover:bg-kodo-cyan-dim shadow-neon-cyan" : "bg-white/5 text-kodo-secondary hover:bg-white/10"
)}
disabled={!currentConversationId || (!message.trim() && attachments.length === 0) || isUploading} disabled={!currentConversationId || (!message.trim() && attachments.length === 0) || isUploading}
> >
{isUploading ? ( {isUploading ? (
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <div className="w-5 h-5 border-2 border-kodo-void/30 border-t-kodo-void rounded-full animate-spin" />
) : ( ) : (
<Send size={20} /> <Send size={18} className={cn(message.trim() ? "translate-x-0.5" : "")} />
)} )}
</button> </Button>
</form> </form>
</div> </div>
); );
}; };
// Helper for class names since Lucide and ShadUI might be used
function cn(...classes: (string | boolean | undefined)[]) {
return classes.filter(Boolean).join(' ');
}

View file

@ -2,12 +2,12 @@ import React, { useState, lazy, Suspense } from 'react';
import { ChatMessage } from '../store/chatStore'; import { ChatMessage } from '../store/chatStore';
import { useAuthStore } from '@/features/auth/store/authStore'; import { useAuthStore } from '@/features/auth/store/authStore';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Smile, MoreHorizontal } from 'lucide-react'; import { Smile, MoreHorizontal, Check, CheckCheck } from 'lucide-react';
import { useChat } from '../hooks/useChat'; import { useChat } from '../hooks/useChat';
// PERF: Lazy load EmojiPicker (composant volumineux ~200KB)
const EmojiPicker = lazy(() => import('emoji-picker-react').then(module => ({ default: module.default })));
import { Theme } from 'emoji-picker-react';
import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { Theme } from 'emoji-picker-react';
const EmojiPicker = lazy(() => import('emoji-picker-react').then(module => ({ default: module.default })));
interface ChatMessageProps { interface ChatMessageProps {
message: ChatMessage; message: ChatMessage;
@ -29,49 +29,52 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
return ( return (
<div <div
className={cn( className={cn(
'group flex flex-col gap-1 p-1 max-w-[80%] my-1', 'group flex flex-col gap-1 max-w-[80%] mb-4 relative',
isMe ? 'ml-auto items-end' : 'mr-auto items-start', isMe ? 'ml-auto items-end' : 'mr-auto items-start',
)} )}
> >
<div className="flex items-center gap-2 px-2"> <div className="flex items-center gap-2 px-1 mb-0.5">
<span className="font-semibold text-xs opacity-70"> <span className={cn(
{isMe "font-mono text-[10px] uppercase tracking-wider",
? 'Moi' isMe ? "text-kodo-cyan" : "text-kodo-magenta"
: message.sender_username || `Utilisateur ${message.sender_id.slice(0, 8)}`} )}>
{isMe ? 'You' : message.sender_username || 'Unknown_Signal'}
</span> </span>
<span className="text-[10px] opacity-50"> <span className="text-[9px] text-kodo-secondary/60">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span> </span>
</div> </div>
<div className="relative flex items-center gap-2"> <div className="relative flex items-end gap-2 group/bubble">
{/* Emoji Button (Left for Me) */}
{isMe && ( {isMe && (
<button <button
onClick={() => setShowEmojiPicker(!showEmojiPicker)} onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-100 rounded-full transition-opacity" className="opacity-0 group-hover/bubble:opacity-100 p-1.5 hover:bg-white/10 rounded-full transition-all text-kodo-secondary hover:text-kodo-cyan"
> >
<Smile size={16} className="text-gray-500" /> <Smile size={14} />
</button> </button>
)} )}
{/* Message Bubble */}
<div <div
className={cn( className={cn(
'px-3 py-2 rounded-2xl text-sm shadow-sm', 'px-4 py-2.5 rounded-2xl text-sm backdrop-blur-md shadow-lg transition-all',
isMe isMe
? 'bg-blue-600 text-white rounded-tr-none' ? 'bg-kodo-cyan/10 border border-kodo-cyan/20 text-white rounded-tr-sm shadow-[0_0_15px_rgba(102,252,241,0.05)]'
: 'bg-white border text-gray-800 rounded-tl-none', : 'bg-white/5 border border-white/10 text-gray-100 rounded-tl-sm hover:bg-white/10',
)} )}
> >
{/* Attachments */} {/* Attachments */}
{message.attachments && message.attachments.length > 0 && ( {message.attachments && message.attachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2"> <div className="mb-2 flex flex-wrap gap-2">
{message.attachments.map((att, i) => ( {message.attachments.map((att, i) => (
<div key={i} className="max-w-full overflow-hidden rounded-lg"> <div key={i} className="max-w-full overflow-hidden rounded-lg border border-white/10 bg-black/20">
{att.file_type.startsWith('image') ? ( {att.file_type.startsWith('image') ? (
<img <img
src={att.file_url} src={att.file_url}
alt={att.file_name} alt={att.file_name}
className="max-h-60 object-contain cursor-pointer hover:opacity-90" className="max-h-60 object-contain cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => window.open(att.file_url, '_blank')} onClick={() => window.open(att.file_url, '_blank')}
/> />
) : ( ) : (
@ -79,10 +82,12 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
href={att.file_url} href={att.file_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-2 p-2 bg-gray-100 text-gray-800 rounded hover:bg-gray-200" className="flex items-center gap-3 p-3 hover:bg-white/5 transition-colors"
> >
<MoreHorizontal size={16} /> <div className="w-8 h-8 rounded bg-white/10 flex items-center justify-center">
<span className="truncate max-w-[150px]">{att.file_name}</span> <MoreHorizontal size={16} className="text-kodo-cyan" />
</div>
<span className="truncate max-w-[150px] text-xs font-mono">{att.file_name}</span>
</a> </a>
)} )}
</div> </div>
@ -90,27 +95,34 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
</div> </div>
)} )}
<p className="whitespace-pre-wrap break-words">{message.content}</p> <p className="whitespace-pre-wrap break-words leading-relaxed">{message.content}</p>
</div> </div>
{/* Emoji Button (Right for Others) */}
{!isMe && ( {!isMe && (
<button <button
onClick={() => setShowEmojiPicker(!showEmojiPicker)} onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-100 rounded-full transition-opacity" className="opacity-0 group-hover/bubble:opacity-100 p-1.5 hover:bg-white/10 rounded-full transition-all text-kodo-secondary hover:text-kodo-cyan"
> >
<Smile size={16} className="text-gray-500" /> <Smile size={14} />
</button> </button>
)} )}
{/* Emoji Picker Popover */}
{showEmojiPicker && ( {showEmojiPicker && (
<div className="absolute z-50 bottom-full mb-2"> <div className={cn(
"absolute z-50 bottom-full mb-2",
isMe ? "right-0" : "left-0"
)}>
<div className="fixed inset-0" onClick={() => setShowEmojiPicker(false)} /> <div className="fixed inset-0" onClick={() => setShowEmojiPicker(false)} />
<div className="relative"> <div className="relative shadow-2xl rounded-xl overflow-hidden border border-white/10 animate-scaleIn">
<Suspense fallback={<div className="w-[352px] h-[435px] bg-white rounded-lg flex items-center justify-center"><LoadingSpinner size="sm" /></div>}> <Suspense fallback={<div className="w-[300px] h-[400px] bg-kodo-ink flex items-center justify-center"><LoadingSpinner size="sm" /></div>}>
<EmojiPicker <EmojiPicker
onEmojiClick={handleEmojiClick} onEmojiClick={handleEmojiClick}
theme={Theme.LIGHT} theme={Theme.DARK}
lazyLoadEmojis={true} lazyLoadEmojis={true}
width={300}
height={400}
/> />
</Suspense> </Suspense>
</div> </div>
@ -118,30 +130,32 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
)} )}
</div> </div>
{/* Reactions Display */} {/* Footer (Reactions + Status) */}
{message.reactions && Object.keys(message.reactions).length > 0 && ( <div className="flex items-center justify-between w-full px-1 mt-1">
<div className={cn( <div className="flex flex-wrap gap-1">
"flex flex-wrap gap-1 px-1", {message.reactions && Object.entries(message.reactions).map(([emoji, users]) => (
isMe ? "justify-end" : "justify-start"
)}>
{Object.entries(message.reactions).map(([emoji, users]) => (
<button <button
key={emoji} key={emoji}
onClick={() => addReaction(message.id, emoji)} onClick={() => addReaction(message.id, emoji)}
className={cn( className={cn(
"flex items-center gap-1 px-1.5 py-0.5 rounded-full text-xs border transition-all", "flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] border transition-all animate-scaleIn",
users.includes(user?.id || '') users.includes(user?.id || '')
? "bg-blue-50 border-blue-200 text-blue-700" ? "bg-kodo-cyan/20 border-kodo-cyan/40 text-kodo-cyan shadow-[0_0_10px_rgba(102,252,241,0.2)]"
: "bg-gray-50 border-gray-100 text-gray-600 hover:border-gray-200" : "bg-white/5 border-white/10 text-kodo-secondary hover:bg-white/10 hover:border-white/20"
)} )}
title={users.length > 1 ? `${users.length} personnes ont réagi` : "1 personne a réagi"}
> >
<span>{emoji}</span> <span>{emoji}</span>
{users.length > 1 && <span className="font-semibold">{users.length}</span>} {users.length > 1 && <span className="font-bold">{users.length}</span>}
</button> </button>
))} ))}
</div> </div>
{isMe && (
<div className="text-kodo-secondary/40 ml-auto">
<CheckCheck size={12} />
</div>
)} )}
</div> </div>
</div>
); );
}; };

View file

@ -4,67 +4,68 @@ import { ChatMessageComponent } from './ChatMessage';
import { useChat } from '../hooks/useChat'; import { useChat } from '../hooks/useChat';
import { MessageSearch } from './MessageSearch'; import { MessageSearch } from './MessageSearch';
import { TypingIndicator } from './TypingIndicator'; import { TypingIndicator } from './TypingIndicator';
import { Search, X } from 'lucide-react'; import { Search, X, Wifi } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
// FE-PAGE-005: Complete Chat page implementation
interface ChatRoomProps { interface ChatRoomProps {
conversationId: string; conversationId: string;
} }
export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => { export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
const { messages } = useChatStore(); const { messages, wsStatus } = useChatStore();
const { fetchHistory } = useChat(); const { fetchHistory } = useChat();
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null); const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null);
const currentMessages = messages[conversationId] || []; const currentMessages = messages[conversationId] || [];
// FE-BUG-002: Use a ref to track if we've already tried fetching to avoid infinite loops on failure
const fetchingRef = useRef<{ [key: string]: boolean }>({}); const fetchingRef = useRef<{ [key: string]: boolean }>({});
useEffect(() => { useEffect(() => {
if (conversationId && !messages[conversationId] && !fetchingRef.current[conversationId]) { if (conversationId && !messages[conversationId] && !fetchingRef.current[conversationId]) {
fetchingRef.current[conversationId] = true; fetchingRef.current[conversationId] = true;
fetchHistory(conversationId).finally(() => { fetchHistory(conversationId).finally(() => {
// We keep it true to avoid re-fetching the same ID in this session // Fetch complete
// if it returned nothing, or we could reset it if we want to allow retry.
// For now, let's just make sure it doesn't loop.
}); });
} }
}, [conversationId, messages[conversationId], fetchHistory]); }, [conversationId, messages[conversationId], fetchHistory]);
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); if (messagesEndRef.current) {
}, [currentMessages]); messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [currentMessages.length, conversationId]); // Scroll on new messages or channel switch
const handleMessageSelect = (messageId: string) => { const handleMessageSelect = (messageId: string) => {
setHighlightedMessageId(messageId); setHighlightedMessageId(messageId);
// Scroll to message
const messageElement = document.getElementById(`message-${messageId}`); const messageElement = document.getElementById(`message-${messageId}`);
if (messageElement) { if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Remove highlight after 3 seconds
setTimeout(() => setHighlightedMessageId(null), 3000); setTimeout(() => setHighlightedMessageId(null), 3000);
} }
}; };
if (!conversationId) { if (!conversationId) {
return ( return (
<div className="flex-1 flex items-center justify-center text-gray-500"> <div className="flex-1 flex flex-col items-center justify-center text-kodo-secondary opacity-50 space-y-4">
Sélectionnez une conversation pour commencer <div className="w-24 h-24 rounded-full bg-white/5 flex items-center justify-center animate-pulse">
<Wifi className="w-10 h-10 text-kodo-cyan opacity-50" />
</div>
<p className="text-sm font-mono uppercase tracking-widest">Awaiting Frequency Selection</p>
</div> </div>
); );
} }
return ( return (
<div className="flex-1 flex flex-col h-full bg-white"> <div className="flex-1 flex flex-col h-full overflow-hidden">
{/* FE-PAGE-005: Message Search Bar */} {/* Search Header Overlay */}
<div className="border-b p-2 bg-gray-50"> <div className={cn(
"absolute top-0 left-0 right-0 z-20 px-4 py-2 transition-all duration-300",
showSearch ? "bg-kodo-void/90 backdrop-blur-md border-b border-white/10" : "bg-transparent pointer-events-none"
)}>
{showSearch ? ( {showSearch ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 max-w-2xl mx-auto">
<div className="flex-1"> <div className="flex-1">
<MessageSearch <MessageSearch
conversationId={conversationId} conversationId={conversationId}
@ -75,47 +76,61 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setShowSearch(false)} onClick={() => setShowSearch(false)}
className="hover:bg-white/10"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="flex justify-end"> <div className="flex justify-end pointer-events-auto">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setShowSearch(true)} onClick={() => setShowSearch(true)}
className="text-kodo-secondary/50 hover:text-kodo-cyan hover:bg-white/5 bg-black/20 backdrop-blur-sm rounded-full h-8 px-4 border border-white/5"
> >
<Search className="h-4 w-4 mr-2" /> <Search className="h-3 w-3 mr-2" />
Search Messages <span className="text-xs font-mono uppercase">Search Log</span>
</Button> </Button>
</div> </div>
)} )}
</div> </div>
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 scroll-smooth">
{currentMessages.length === 0 ? ( {/* Welcome Message for Empty Room */}
<div className="flex items-center justify-center h-full text-gray-500"> {currentMessages.length === 0 && (
Aucun message. Soyez le premier à envoyer un message ! <div className="flex flex-col items-center justify-center h-[50vh] text-center space-y-3 opacity-60">
<div className="w-12 h-12 rounded-xl bg-kodo-cyan/10 flex items-center justify-center border border-kodo-cyan/20">
<MessageSquare className="w-6 h-6 text-kodo-cyan" />
</div> </div>
) : ( <div>
currentMessages.map((msg) => ( <p className="text-white font-medium">Channel Established</p>
<p className="text-sm text-kodo-secondary mt-1">Begin transmission on this frequency.</p>
</div>
</div>
)}
{/* Message Stream */}
{currentMessages.map((msg, index) => {
const isMe = false; // TODO: Check with current user ID from store
const isSequence = index > 0 && currentMessages[index - 1].sender_id === msg.sender_id;
return (
<div <div
key={msg.id} key={msg.id}
id={`message-${msg.id}`} id={`message-${msg.id}`}
className={ className={cn(
highlightedMessageId === msg.id "transition-all duration-500 animate-slideUp",
? 'bg-yellow-100 rounded-lg p-2 -m-2 mb-2' highlightedMessageId === msg.id && "bg-kodo-cyan/10 rounded-xl -mx-4 px-4 py-2 ring-1 ring-kodo-cyan/30"
: '' )}
}
> >
<ChatMessageComponent message={msg} /> <ChatMessageComponent message={msg} />
</div> </div>
)) );
)} })}
{/* FE-PAGE-005: Typing Indicator */}
<TypingIndicator conversationId={conversationId} /> <TypingIndicator conversationId={conversationId} />
<div ref={messagesEndRef} /> <div ref={messagesEndRef} className="h-4" />
</div> </div>
</div> </div>
); );

View file

@ -4,7 +4,7 @@ import { useAuthStore } from '@/features/auth/store/authStore';
import { apiClient } from '@/services/api/client'; import { apiClient } from '@/services/api/client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Loader2, Plus, Trash2, LogOut } from 'lucide-react'; import { Loader2, Plus, Trash2, LogOut, MessageSquare, Hash, User, MoreVertical } from 'lucide-react';
import { CreateRoomDialog } from './CreateRoomDialog'; import { CreateRoomDialog } from './CreateRoomDialog';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -14,13 +14,10 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { MoreVertical } from 'lucide-react';
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'; import { ConfirmationDialog } from '@/components/ui/confirmation-dialog';
// FE-PAGE-005: Complete Chat page implementation - Room Management
interface ConversationItemProps { interface ConversationItemProps {
conversation: { id: string; name: string; type: string }; conversation: { id: string; name: string; type: string; unread_count?: number };
onSelect: (id: string) => void; onSelect: (id: string) => void;
isSelected: boolean; isSelected: boolean;
} }
@ -90,44 +87,81 @@ const ConversationItem: React.FC<ConversationItemProps> = ({
<div <div
onClick={() => onSelect(conversation.id)} onClick={() => onSelect(conversation.id)}
className={cn( className={cn(
'flex items-center justify-between p-3 rounded-lg cursor-pointer transition-colors group', 'group relative flex items-center justify-between p-3 rounded-xl cursor-pointer transition-all duration-300 border border-transparent',
isSelected ? 'bg-blue-100 text-blue-800' : 'hover:bg-gray-100', isSelected
? 'bg-kodo-cyan/10 border-kodo-cyan/30 shadow-[0_0_15px_rgba(102,252,241,0.1)]'
: 'hover:bg-white/5 hover:border-white/5'
)} )}
> >
<span className="font-medium flex-1 truncate"> <div className="flex items-center gap-3 min-w-0">
{conversation.name || `Conversation ${conversation.id.substring(0, 8)}`} <div className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center transition-colors",
isSelected ? "bg-kodo-cyan text-kodo-void" : "bg-white/5 text-kodo-secondary group-hover:text-white"
)}>
{conversation.type === 'direct' ? <User size={14} /> : <Hash size={14} />}
</div>
<div className="flex flex-col min-w-0">
<span className={cn(
"text-sm font-medium truncate transition-colors",
isSelected ? "text-white" : "text-kodo-secondary group-hover:text-white"
)}>
{conversation.name || `Channel ${conversation.id.substring(0, 4)}`}
</span> </span>
{conversation.type !== 'direct' && (
<span className="text-[10px] text-kodo-secondary/50 uppercase tracking-wider">
{conversation.type}
</span>
)}
</div>
</div>
{conversation.unread_count && conversation.unread_count > 0 ? (
<span className="bg-kodo-magenta text-white text-[10px] px-1.5 py-0.5 rounded-full font-bold shadow-lg shadow-kodo-magenta/20">
{conversation.unread_count}
</span>
) : null}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100" className={cn(
"h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity",
isSelected ? "text-kodo-cyan hover:bg-kodo-cyan/20" : "text-kodo-secondary hover:text-white"
)}
> >
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="bg-kodo-void border-white/10 text-white">
<DropdownMenuItem onClick={handleLeave}> <DropdownMenuItem onClick={handleLeave} className="focus:bg-white/10 cursor-pointer">
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
Leave Room Leave Channel
</DropdownMenuItem> </DropdownMenuItem>
{conversation.type !== 'direct' && ( {conversation.type !== 'direct' && (
<DropdownMenuItem onClick={handleDelete} className="text-destructive"> <DropdownMenuItem onClick={handleDelete} className="text-kodo-red focus:bg-kodo-red/10 cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Room Delete Channel
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Active Indicator Line */}
{isSelected && (
<div className="absolute left-0 top-3 bottom-3 w-0.5 bg-kodo-cyan rounded-r-full shadow-[0_0_8px_rgba(102,252,241,0.8)]" />
)}
</div> </div>
<ConfirmationDialog <ConfirmationDialog
open={showLeaveDialog} open={showLeaveDialog}
onClose={() => setShowLeaveDialog(false)} onClose={() => setShowLeaveDialog(false)}
onConfirm={confirmLeave} onConfirm={confirmLeave}
title="Leave Room" title="Leave Channel"
description="Are you sure you want to leave this room? You will no longer receive messages from this conversation." description="Disconnect from this secure frequency? Incoming transmission will cease."
confirmLabel="Leave" confirmLabel="Disconnect"
cancelLabel="Cancel" cancelLabel="Cancel"
variant="default" variant="default"
isLoading={leaveRoomMutation.isPending} isLoading={leaveRoomMutation.isPending}
@ -136,9 +170,9 @@ const ConversationItem: React.FC<ConversationItemProps> = ({
open={showDeleteDialog} open={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)} onClose={() => setShowDeleteDialog(false)}
onConfirm={confirmDelete} onConfirm={confirmDelete}
title="Delete Room" title="Delete Channel"
description="Are you sure you want to delete this room? This action cannot be undone. All messages and participants will be removed." description="Permanently purge this channel from the network? This action is irreversible."
confirmLabel="Delete" confirmLabel="Purge"
cancelLabel="Cancel" cancelLabel="Cancel"
variant="destructive" variant="destructive"
isLoading={deleteRoomMutation.isPending} isLoading={deleteRoomMutation.isPending}
@ -158,7 +192,6 @@ export const ChatSidebar: React.FC = () => {
} = useChatStore(); } = useChatStore();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
// Fetch conversations from backend
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['chatConversations', userId], queryKey: ['chatConversations', userId],
queryFn: async () => { queryFn: async () => {
@ -172,7 +205,6 @@ export const ChatSidebar: React.FC = () => {
useEffect(() => { useEffect(() => {
if (data) { if (data) {
data.forEach((conv: any) => { data.forEach((conv: any) => {
// Only call addConversation if not already in store to avoid re-render trigger
if (!conversations.some(c => c.id === conv.id)) { if (!conversations.some(c => c.id === conv.id)) {
addConversation({ addConversation({
id: conv.id, id: conv.id,
@ -188,30 +220,40 @@ export const ChatSidebar: React.FC = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="w-64 border-r bg-gray-50 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<Loader2 className="animate-spin text-blue-500" size={24} /> <Loader2 className="animate-spin text-kodo-cyan" size={24} />
</div> </div>
); );
} }
if (error) { if (error) {
return ( return (
<div className="w-64 border-r bg-gray-50 flex items-center justify-center text-red-500 p-4"> <div className="flex-1 flex items-center justify-center p-4 text-center">
Erreur:{' '} <p className="text-kodo-red text-sm font-mono">Signal Lost</p>
{(error as any).message || 'Impossible de charger les conversations'}
</div> </div>
); );
} }
return ( return (
<div className="w-64 border-r bg-gray-50 flex flex-col"> <div className="flex flex-col h-full">
<div className="p-4 border-b"> <div className="p-4 border-b border-white/5 bg-white/2 backdrop-blur-sm">
<h2 className="text-xl font-bold">Conversations</h2> <div className="flex items-center justify-between mb-1">
<h2 className="text-sm font-bold text-white tracking-wide uppercase flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-kodo-cyan" />
Active Channels
</h2>
<span className="text-[10px] font-mono text-kodo-secondary bg-white/5 px-1.5 py-0.5 rounded">
{conversations.length}
</span>
</div> </div>
<div className="flex-1 overflow-y-auto p-2"> </div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-3 space-y-1">
{conversations.length === 0 ? ( {conversations.length === 0 ? (
<div className="text-gray-500 text-sm p-2"> <div className="text-kodo-secondary/50 text-sm p-4 text-center italic border border-dashed border-white/5 rounded-xl m-2">
Aucune conversation. Créez-en une ! No active frequencies detected.
<br />
Initialize a new channel.
</div> </div>
) : ( ) : (
conversations.map((conv) => ( conversations.map((conv) => (
@ -224,14 +266,15 @@ export const ChatSidebar: React.FC = () => {
)) ))
)} )}
</div> </div>
<div className="p-4 border-t">
<div className="p-4 border-t border-white/5 bg-white/2 backdrop-blur-sm">
<Button <Button
onClick={() => setIsCreateDialogOpen(true)} onClick={() => setIsCreateDialogOpen(true)}
className="w-full" className="w-full shadow-lg shadow-kodo-cyan/10"
variant="default" variant="default"
> >
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Nouvelle Conversation New Channel
</Button> </Button>
</div> </div>
<CreateRoomDialog <CreateRoomDialog

View file

@ -6,18 +6,16 @@ import { useChatStore } from '../store/chatStore';
import { useAuthStore } from '@/features/auth/store/authStore'; import { useAuthStore } from '@/features/auth/store/authStore';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/services/api/client'; import { apiClient } from '@/services/api/client';
import { useChat } from '../hooks/useChat'; import { Loader2, AlertCircle } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { env } from '@/config/env'; import { env } from '@/config/env';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
// This page needs to fetch the WS token first
export const ChatPage: React.FC = () => { export const ChatPage: React.FC = () => {
const { user, isAuthenticated } = useAuthStore(); const { user, isAuthenticated } = useAuthStore();
const userId = user?.id; // Derived const userId = user?.id;
const { setWsToken, currentConversationId, wsStatus } = useChatStore(); const { setWsToken, currentConversationId, wsStatus } = useChatStore();
const { disconnect: _disconnect } = useChat(); // disconnect available but unused here
// CRITIQUE FIX #52: Fetch WS Token avec cache pour éviter les requêtes multiples
const { const {
data: wsTokenResponse, data: wsTokenResponse,
isLoading: isTokenLoading, isLoading: isTokenLoading,
@ -29,18 +27,15 @@ export const ChatPage: React.FC = () => {
const response = await apiClient.post('/chat/token', {}); const response = await apiClient.post('/chat/token', {});
return response.data; return response.data;
}, },
enabled: isAuthenticated && !!userId && wsStatus === 'disconnected', // Only fetch if authenticated and not connected enabled: isAuthenticated && !!userId && wsStatus === 'disconnected',
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: false, retry: false,
staleTime: 5 * 60 * 1000, // CRITIQUE FIX #52: Cache le token pendant 5 minutes pour éviter les requêtes multiples staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000, // Garder en cache pendant 10 minutes gcTime: 10 * 60 * 1000,
}); });
useEffect(() => { useEffect(() => {
if (wsTokenResponse?.token) { if (wsTokenResponse?.token) {
// FE-BUG-001: Check if values actually changed to avoid infinite loop
// useChat already has an internal useEffect that calls connect() when wsToken/wsUrl change
// Use env.WS_URL instead of API response ws_url which is just a relative path
const needsUpdate = wsTokenResponse.token !== useChatStore.getState().wsToken || const needsUpdate = wsTokenResponse.token !== useChatStore.getState().wsToken ||
env.WS_URL !== useChatStore.getState().wsUrl; env.WS_URL !== useChatStore.getState().wsUrl;
@ -48,42 +43,77 @@ export const ChatPage: React.FC = () => {
setWsToken(wsTokenResponse.token, env.WS_URL); setWsToken(wsTokenResponse.token, env.WS_URL);
} }
} }
}, [wsTokenResponse, setWsToken]); // connect removed from dependencies to avoid loop via useChat internal status changes }, [wsTokenResponse, setWsToken]);
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<div className="flex flex-col items-center justify-center h-full text-gray-500"> <div className="flex flex-col items-center justify-center h-[calc(100vh-100px)] text-kodo-secondary glass-hud rounded-2xl">
Vous devez être connecté pour utiliser le chat. <div className="p-8 text-center max-w-md">
<AlertCircle className="w-12 h-12 text-kodo-cyan mx-auto mb-4 opacity-50" />
<h2 className="text-xl font-bold text-white mb-2">Access Restricted</h2>
<p className="mb-6">Authorization required to access secure communication channels.</p>
<Button variant="default" onClick={() => window.location.href = '/login'}>
Initialize Login
</Button>
</div>
</div> </div>
); );
} }
if (isTokenLoading || wsStatus === 'connecting') { if (isTokenLoading || wsStatus === 'connecting') {
return ( return (
<div className="flex flex-col items-center justify-center h-full"> <div className="flex flex-col items-center justify-center h-[calc(100vh-100px)] glass-hud rounded-2xl">
<Loader2 className="animate-spin text-blue-500" size={32} /> <div className="relative">
<p className="mt-4 text-gray-600">Chargement du chat...</p> <div className="w-16 h-16 border-4 border-kodo-cyan/20 border-t-kodo-cyan rounded-full animate-spin" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-2 bg-kodo-cyan rounded-full animate-pulse" />
</div>
</div>
<p className="mt-6 text-sm font-mono text-kodo-cyan animate-pulse uppercase tracking-widest">
Establishing Uplink...
</p>
</div> </div>
); );
} }
if (tokenError) { if (tokenError) {
return ( return (
<div className="flex flex-col items-center justify-center h-full text-red-500"> <div className="flex flex-col items-center justify-center h-[calc(100vh-100px)] glass-hud rounded-2xl border-kodo-red/20">
Erreur:{' '} <AlertCircle className="w-12 h-12 text-kodo-red mb-4" />
{(tokenError as any).message || <h2 className="text-xl font-bold text-white mb-2">Connection Failure</h2>
'Impossible de récupérer le token du chat'} <p className="text-kodo-red/80 max-w-md text-center">
{(tokenError as any).message || 'Unable to retrieve secure token via Handshake Protocol.'}
</p>
</div> </div>
); );
} }
return ( return (
<div className="flex h-full max-h-screen bg-gray-50"> <div className="h-[calc(100vh-theme(spacing.24))] flex gap-6 overflow-hidden animate-fadeIn">
{/* Sidebar Container */}
<div className="w-80 shrink-0 flex flex-col glass-hud rounded-2xl border-white/5 overflow-hidden">
<ChatSidebar /> <ChatSidebar />
<div className="flex-1 flex flex-col"> </div>
{/* Chat Area Container */}
<div className="flex-1 flex flex-col glass-hud rounded-2xl border-white/5 overflow-hidden relative">
{/* Background Grid Effect */}
<div
className="absolute inset-0 opacity-[0.02] pointer-events-none"
style={{
backgroundImage: 'linear-gradient(rgba(102, 252, 241, 0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(102, 252, 241, 0.5) 1px, transparent 1px)',
backgroundSize: '20px 20px'
}}
/>
<div className="flex-1 overflow-hidden flex flex-col relative z-10">
<ChatRoom conversationId={currentConversationId || ''} /> <ChatRoom conversationId={currentConversationId || ''} />
</div>
<div className="p-4 border-t border-white/5 bg-black/20 backdrop-blur-xl relative z-20">
<ChatInput /> <ChatInput />
</div> </div>
</div> </div>
</div>
); );
}; };