chat-app/frontend/src/components/ChatSidebar.tsx
2025-06-12 03:41:13 +02:00

229 lines
8.4 KiB
TypeScript

import { chatAPI } from '@/lib/api';
import { useAuthStore } from '@/stores/authStore';
import { useChatStore } from '@/stores/chatStore';
import type { ChatRoom, CreateChatRoomRequest } from '@/types';
import { useMutation, useQuery } from '@tanstack/react-query';
import { LogOut, MessageCircle, Plus, Users } from 'lucide-react';
import { useState, type FC, type FormEvent } from 'react';
import { toast } from 'sonner';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { ScrollArea } from './ui/scroll-area';
import { Textarea } from './ui/textarea';
type ChatSidebarProps = {
selectedRoomId: string | null;
onRoomSelect: (roomId: string) => void;
};
export const ChatSidebar: FC<ChatSidebarProps> = ({
selectedRoomId,
onRoomSelect,
}) => {
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [newRoomData, setNewRoomData] = useState<CreateChatRoomRequest>({
name: '',
description: '',
memberUsernames: [],
});
const { user, logout } = useAuthStore();
const { chatRooms, onlineUsers, setChatRooms, addChatRoom } = useChatStore();
const { isLoading } = useQuery({
queryKey: ['chatRooms'],
queryFn: async () => {
const response = await chatAPI.getChatRooms();
if (response.data.success) {
setChatRooms(response.data.data);
return response.data.data;
}
throw new Error(response.data.error ?? 'Failed to fetch chat rooms');
},
});
const createRoomMutation = useMutation({
mutationKey: ['createChatRoom'],
mutationFn: chatAPI.createChatRoom,
onSuccess: (response) => {
if (response.data.success) {
addChatRoom(response.data.data);
setIsCreateDialogOpen(false);
setNewRoomData({ name: '', description: '', memberUsernames: [] });
toast.success('Chat room created successfully');
}
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (error: any) => {
toast.error(error.response?.data?.error ?? 'Failed to create chat room');
},
});
const handleCreateRoom = (e: FormEvent) => {
e.preventDefault();
createRoomMutation.mutate(newRoomData);
};
const getOnlineMembersCount = (room: ChatRoom) => {
return room.members.filter((member) => onlineUsers.has(member.userId))
.length;
};
if (isLoading) {
return (
<div className="w-80 bg-gray-50 border-r border-gray-200 p-4">
<div className="animate-pulse space-y-4">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
</div>
);
}
return (
<div className="w-80 bg-gray-50 border-r border-gray-200 flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Chat Rooms</h2>
<Dialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Chat Room</DialogTitle>
</DialogHeader>
<form onSubmit={handleCreateRoom} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="room-name">Room Name</Label>
<Input
id="room-name"
value={newRoomData.name}
onChange={(e) =>
setNewRoomData({ ...newRoomData, name: e.target.value })
}
required
placeholder="Enter room name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="room-description">
Description (optional)
</Label>
<Textarea
id="room-description"
value={newRoomData.description}
onChange={(e) =>
setNewRoomData({
...newRoomData,
description: e.target.value,
})
}
placeholder="Enter room description"
/>
</div>
<div className="space-y-2">
<Label htmlFor="member-usernames">
Member Usernames (comma-separated)
</Label>
<Input
id="member-usernames"
value={newRoomData.memberUsernames.join(', ')}
onChange={(e) =>
setNewRoomData({
...newRoomData,
memberUsernames: e.target.value
.split(',')
.map((u) => u.trim())
.filter((u) => u.length > 0),
})
}
placeholder="user1, user2, user3"
/>
</div>
<Button
type="submit"
className="w-full"
disabled={createRoomMutation.isPending}
>
{createRoomMutation.isPending ? 'Creating...' : 'Create Room'}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
<div className="flex items-center space-x-2 text-sm text-gray-600">
<span>Welcome, {user?.username}</span>
<Button size="sm" variant="ghost" onClick={logout}>
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-2 space-y-2">
{chatRooms.map((room: ChatRoom) => (
<div
key={room.id}
className={`p-3 rounded-lg cursor-pointer transition-colors ${selectedRoomId === room.id ? 'bg-blue-100 border-blue-200 border' : 'bg-white hover:bg-gray-100 border border-gray-200'}`}
onClick={() => onRoomSelect(room.id)}
>
<div className="flex items-center justify-between mb-1">
<h3 className="font-medium text-sm">{room.name}</h3>
<div className="flex items-center space-x-1">
<Badge variant="secondary" className="text-xs">
<Users className="h-3 w-3 mr-1" />
{getOnlineMembersCount(room) / room.members.length}
</Badge>
</div>
</div>
{room.description && (
<p className="text-xs text-gray-500 mb-2 truncate">
{room.description}
</p>
)}
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center">
<MessageCircle className="h-3 w-3 mr-1" />
{room._count.messages} messages
</div>
<div className="flex -space-x-1">
{room.members.slice(0, 3).map((member) => (
<div
key={member.id}
className={`w-4 h-4 rounded-full border border-white flex items-center justify-center text-xs font-medium ${onlineUsers.has(member.userId) ? 'bg-green-500 text-white' : 'bg-gray-300 text-gray-600 '}`}
title={`${member.user.username} - ${onlineUsers.has(member.userId) ? 'Online' : 'Offline'}`}
>
{member.user.username.charAt(0).toUpperCase()}
</div>
))}
{room.members.length > 3 && (
<div className="w-4 h-4 rounded-full border border-white bg-gray-200 flex items-center justify-center text-xs">
+{room.members.length - 3}
</div>
)}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
);
};