229 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
};
|