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:
parent
62898a8916
commit
e127a6a48c
5 changed files with 328 additions and 234 deletions
|
|
@ -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 */}
|
||||||
|
{isDragActive && (
|
||||||
|
<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">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-kodo-cyan/20 flex items-center justify-center mx-auto mb-2 animate-bounce">
|
||||||
|
<Paperclip className="w-6 h-6 text-kodo-cyan" />
|
||||||
|
</div>
|
||||||
|
<p className="text-kodo-cyan font-mono uppercase tracking-widest text-sm">Initiate Data Transfer</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attachments Preview */}
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 p-2 px-4 border-b bg-white">
|
<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) => (
|
{attachments.map((att, i) => (
|
||||||
<div key={i} className="relative group flex items-center gap-2 p-1.5 bg-gray-100 rounded-md border text-xs">
|
<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') ? (
|
{att.file_type.startsWith('image') ? (
|
||||||
<ImageIcon size={14} className="text-blue-500" />
|
<ImageIcon size={14} className="text-kodo-cyan" />
|
||||||
) : (
|
) : (
|
||||||
<File size={14} className="text-gray-500" />
|
<File size={14} className="text-kodo-secondary" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate max-w-[100px]">{att.file_name}</span>
|
<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>
|
||||||
|
|
||||||
<input
|
<div className="flex-1 relative">
|
||||||
type="text"
|
<input
|
||||||
value={message}
|
type="text"
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
value={message}
|
||||||
placeholder="Écrire un message..."
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
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"
|
placeholder="Broadcast message..."
|
||||||
disabled={!currentConversationId || isUploading}
|
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}
|
||||||
|
/>
|
||||||
|
{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(' ');
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -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,48 +76,62 @@ 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>
|
||||||
|
<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>
|
</div>
|
||||||
) : (
|
)}
|
||||||
currentMessages.map((msg) => (
|
|
||||||
|
{/* 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -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(
|
||||||
</span>
|
"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>
|
||||||
|
{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>
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
|
||||||
|
<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
|
||||||
|
|
@ -240,4 +283,4 @@ export const ChatSidebar: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -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">
|
||||||
<ChatSidebar />
|
{/* Sidebar Container */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="w-80 shrink-0 flex flex-col glass-hud rounded-2xl border-white/5 overflow-hidden">
|
||||||
<ChatRoom conversationId={currentConversationId || ''} />
|
<ChatSidebar />
|
||||||
<ChatInput />
|
</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 || ''} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-white/5 bg-black/20 backdrop-blur-xl relative z-20">
|
||||||
|
<ChatInput />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
Loading…
Reference in a new issue