From d03f19354fd667b0bfa968c8ddc855aa0dc430ad Mon Sep 17 00:00:00 2001 From: hieukobtcode Date: Fri, 7 Aug 2026 13:40:01 +0700 Subject: [PATCH] fe: danh sach cuoc tro chuyen them nhom, ca nhan --- frontend/src/App.tsx | 8 + frontend/src/components/auth/Logout.tsx | 5 +- .../chat/{ => layout}/ChatWindowLayout.tsx | 0 .../components/chat/modals/AddFriendModal.tsx | 7 + .../components/chat/modals/CreateNewChat.tsx | 9 + .../chat/modals/NewGroupChatModal.tsx | 9 + .../src/components/chat/shared/ChatCard.tsx | 40 ++++ .../components/chat/shared/StatusBadge.tsx | 14 ++ .../chat/shared/UnreadCountBadge.tsx | 13 ++ .../src/components/chat/shared/UserAvatar.tsx | 30 +++ .../chat/sidebar/DirectMesageList.tsx | 25 ++ .../chat/sidebar/DirectMessageCard.tsx | 55 +++++ .../chat/sidebar/GropuMessageCard.tsx | 46 ++++ .../chat/sidebar/GroupChatAvatar.tsx | 40 ++++ .../components/chat/sidebar/GroupChatList.tsx | 25 ++ .../src/components/sidebar/app-sidebar.tsx | 76 ++++-- frontend/src/components/sidebar/nav-user.tsx | 55 ++--- frontend/src/components/ui/button.tsx | 1 + frontend/src/lib/utils.ts | 63 ++++- frontend/src/pages/ChatAppPage.tsx | 2 +- frontend/src/services/chatService.ts | 9 + frontend/src/stores/useAuthStore.ts | 219 ++++++++++-------- frontend/src/stores/useChatStore.tsx | 40 ++++ frontend/src/stores/useThemeStore.tsx | 34 +++ frontend/src/types/chat.ts | 56 +++++ frontend/src/types/store.ts | 22 ++ frontend/src/types/user.ts | 26 +++ frontend/vite.config.ts | 2 +- 28 files changed, 776 insertions(+), 155 deletions(-) rename frontend/src/components/chat/{ => layout}/ChatWindowLayout.tsx (100%) create mode 100644 frontend/src/components/chat/modals/AddFriendModal.tsx create mode 100644 frontend/src/components/chat/modals/CreateNewChat.tsx create mode 100644 frontend/src/components/chat/modals/NewGroupChatModal.tsx create mode 100644 frontend/src/components/chat/shared/ChatCard.tsx create mode 100644 frontend/src/components/chat/shared/StatusBadge.tsx create mode 100644 frontend/src/components/chat/shared/UnreadCountBadge.tsx create mode 100644 frontend/src/components/chat/shared/UserAvatar.tsx create mode 100644 frontend/src/components/chat/sidebar/DirectMesageList.tsx create mode 100644 frontend/src/components/chat/sidebar/DirectMessageCard.tsx create mode 100644 frontend/src/components/chat/sidebar/GropuMessageCard.tsx create mode 100644 frontend/src/components/chat/sidebar/GroupChatAvatar.tsx create mode 100644 frontend/src/components/chat/sidebar/GroupChatList.tsx create mode 100644 frontend/src/services/chatService.ts create mode 100644 frontend/src/stores/useChatStore.tsx create mode 100644 frontend/src/stores/useThemeStore.tsx create mode 100644 frontend/src/types/chat.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6273e7a..9bf0e67 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,8 +4,16 @@ import ChatAppPage from "./pages/ChatAppPage"; import { Toaster } from "sonner"; import SignUpPage from "./pages/SignUpPage"; import ProtectedRoute from "./components/auth/ProtectedRoute"; +import { useThemeStore } from "./stores/useThemeStore"; +import { useEffect } from "react"; function App() { + + const { isDark, setTheme } = useThemeStore(); + useEffect(() => { + setTheme(isDark) + }, [isDark]) + return ( <> diff --git a/frontend/src/components/auth/Logout.tsx b/frontend/src/components/auth/Logout.tsx index a57e849..ca03f56 100644 --- a/frontend/src/components/auth/Logout.tsx +++ b/frontend/src/components/auth/Logout.tsx @@ -1,5 +1,6 @@ import { Button } from "../ui/button"; import { useAuthStore } from "@/stores/useAuthStore"; +import { LogOut } from "lucide-react"; import { useNavigate } from "react-router"; const Logout = () => { @@ -14,7 +15,9 @@ const Logout = () => { } }; - return ; + return ; }; export default Logout; \ No newline at end of file diff --git a/frontend/src/components/chat/ChatWindowLayout.tsx b/frontend/src/components/chat/layout/ChatWindowLayout.tsx similarity index 100% rename from frontend/src/components/chat/ChatWindowLayout.tsx rename to frontend/src/components/chat/layout/ChatWindowLayout.tsx diff --git a/frontend/src/components/chat/modals/AddFriendModal.tsx b/frontend/src/components/chat/modals/AddFriendModal.tsx new file mode 100644 index 0000000..cb5b84b --- /dev/null +++ b/frontend/src/components/chat/modals/AddFriendModal.tsx @@ -0,0 +1,7 @@ +const AddFriendModal = () => { + return ( +
AddFriendModal
+ ) +} + +export default AddFriendModal \ No newline at end of file diff --git a/frontend/src/components/chat/modals/CreateNewChat.tsx b/frontend/src/components/chat/modals/CreateNewChat.tsx new file mode 100644 index 0000000..96fa54c --- /dev/null +++ b/frontend/src/components/chat/modals/CreateNewChat.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +const CreateNewChat = () => { + return ( +
CreateNewChat
+ ) +} + +export default CreateNewChat \ No newline at end of file diff --git a/frontend/src/components/chat/modals/NewGroupChatModal.tsx b/frontend/src/components/chat/modals/NewGroupChatModal.tsx new file mode 100644 index 0000000..82b21c2 --- /dev/null +++ b/frontend/src/components/chat/modals/NewGroupChatModal.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +const NewGroupChatModal = () => { + return ( +
NewGroupChatModal
+ ) +} + +export default NewGroupChatModal \ No newline at end of file diff --git a/frontend/src/components/chat/shared/ChatCard.tsx b/frontend/src/components/chat/shared/ChatCard.tsx new file mode 100644 index 0000000..551ab17 --- /dev/null +++ b/frontend/src/components/chat/shared/ChatCard.tsx @@ -0,0 +1,40 @@ +import { Card } from "@/components/ui/card"; +import { formatOnlineTime, cn } from "@/lib/utils"; +import { MoreHorizontal } from "lucide-react"; + +interface ChatCardProps { + convoId: string, + name: string, + timestamp?: Date, + isActive: boolean, + onSelect: (id: string) => void, + unreadCounts?: number, + leftSection: React.ReactNode, + subtitle: React.ReactNode +} +const ChatCard = ({ convoId, name, timestamp, isActive, onSelect, unreadCounts, leftSection, subtitle }: ChatCardProps) => { + return ( + onSelect(convoId)} + > +
+
{leftSection}
+
+
+

0 && "text-foreground")}> + {name} +

+ {timestamp ? formatOnlineTime(timestamp) : ""} +
+
+
{subtitle}
+ +
+
+
+
+ ) +} + +export default ChatCard \ No newline at end of file diff --git a/frontend/src/components/chat/shared/StatusBadge.tsx b/frontend/src/components/chat/shared/StatusBadge.tsx new file mode 100644 index 0000000..071bbb6 --- /dev/null +++ b/frontend/src/components/chat/shared/StatusBadge.tsx @@ -0,0 +1,14 @@ +import { cn } from "@/lib/utils" + +const StatusBadge = ({status} : {status: "online" | "offline"}) => { + return ( +
+ +
+ ) +} + +export default StatusBadge \ No newline at end of file diff --git a/frontend/src/components/chat/shared/UnreadCountBadge.tsx b/frontend/src/components/chat/shared/UnreadCountBadge.tsx new file mode 100644 index 0000000..fc29bfd --- /dev/null +++ b/frontend/src/components/chat/shared/UnreadCountBadge.tsx @@ -0,0 +1,13 @@ +import { Badge } from "../../ui/badge" + +const UnreadCountBadge = ({unreadCount} : {unreadCount : number}) => { + return ( +
+ + {unreadCount > 9 ? "9+" : unreadCount} + +
+ ) +} + +export default UnreadCountBadge \ No newline at end of file diff --git a/frontend/src/components/chat/shared/UserAvatar.tsx b/frontend/src/components/chat/shared/UserAvatar.tsx new file mode 100644 index 0000000..d091be7 --- /dev/null +++ b/frontend/src/components/chat/shared/UserAvatar.tsx @@ -0,0 +1,30 @@ +import { cn } from "@/lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "../../ui/avatar"; + +interface IUserAvatarProps { + type: "sidebar" | "chat" | "profile"; + name: string; + avatarUrl?: string; + className?: string +} +const UserAvatar = ({ type, name, avatarUrl, className }: IUserAvatarProps) => { + const bgColor = !avatarUrl ? "bg-blue-500" : ""; + if (!name) { + name = "Chatify"; + } + + return ( + + + + {name.charAt(0)} + + + ) +} + +export default UserAvatar \ No newline at end of file diff --git a/frontend/src/components/chat/sidebar/DirectMesageList.tsx b/frontend/src/components/chat/sidebar/DirectMesageList.tsx new file mode 100644 index 0000000..8edb6ed --- /dev/null +++ b/frontend/src/components/chat/sidebar/DirectMesageList.tsx @@ -0,0 +1,25 @@ +import { useChatStore } from '@/stores/useChatStore' +import DirectMessageCard from './DirectMessageCard'; + +const DirectMesageList = () => { + const { conversations } = useChatStore(); + + if (!conversations) return; + + const directConversations = conversations.filter((convo) => convo.type === "direct"); + + return ( +
+ { + directConversations.map((convo) => ( + + )) + } +
+ ) +} + +export default DirectMesageList \ No newline at end of file diff --git a/frontend/src/components/chat/sidebar/DirectMessageCard.tsx b/frontend/src/components/chat/sidebar/DirectMessageCard.tsx new file mode 100644 index 0000000..8087ad9 --- /dev/null +++ b/frontend/src/components/chat/sidebar/DirectMessageCard.tsx @@ -0,0 +1,55 @@ +import type { Conversation } from "@/types/chat" +import ChatCard from "../shared/ChatCard" +import { useAuthStore } from "@/stores/useAuthStore" +import { useChatStore } from "@/stores/useChatStore"; +import { cn } from "@/lib/utils"; +import UserAvatar from "../shared/UserAvatar"; +import StatusBadge from "../shared/StatusBadge"; +import UnreadCountBadge from "../shared/UnreadCountBadge"; + +const DirectMessageCard = ({convo} : {convo:Conversation}) => { + const {user} = useAuthStore(); + const {activeConversationId , setActiveConversation , messages} = useChatStore(); + + if(!user) return null; + + const otherUser = convo.participants.find((p) => p._id !== user._id); + if(!otherUser) return null; + + const unreadCounts = convo.unreadCounts[user._id]; + const lastMessage = convo.lastMessage?.content ?? ""; + + const handleSelectConversation = async (id:string) => { + setActiveConversation(id); + if(!messages[id]){ + //fetch message + } + } + return ( + + + {/* Soket io */} + + {unreadCounts > 0 && } + + } + subtitle={ +

0 ? "font-medium text-foreground" : "text-muted=foreground")}> + {lastMessage} +

+ } + /> + ) +} + +export default DirectMessageCard \ No newline at end of file diff --git a/frontend/src/components/chat/sidebar/GropuMessageCard.tsx b/frontend/src/components/chat/sidebar/GropuMessageCard.tsx new file mode 100644 index 0000000..ca4f5fd --- /dev/null +++ b/frontend/src/components/chat/sidebar/GropuMessageCard.tsx @@ -0,0 +1,46 @@ +import { useAuthStore } from '@/stores/useAuthStore' +import { useChatStore } from '@/stores/useChatStore'; +import type { Conversation } from '@/types/chat' +import ChatCard from '../shared/ChatCard'; +import UnreadCountBadge from '../shared/UnreadCountBadge'; +import GroupChatAvatar from './GroupChatAvatar'; + +const GropuMessageCard = ({ convo }: { convo: Conversation }) => { + const { user } = useAuthStore(); + const { activeConversationId, setActiveConversation, messages } = useChatStore(); + + if (!user) return null; + + const unreadCounts = convo.unreadCounts[user._id]; + const name = convo.group?.name ?? ""; + + const handleSelectConversation = async (id: string) => { + setActiveConversation(id); + if (!messages[id]) { + //fetch message + } + } + return ( + + {unreadCounts>0 && } + + + } + subtitle={ +

{convo.participants.length} thành viên

+ } + /> + ) +} + +export default GropuMessageCard \ No newline at end of file diff --git a/frontend/src/components/chat/sidebar/GroupChatAvatar.tsx b/frontend/src/components/chat/sidebar/GroupChatAvatar.tsx new file mode 100644 index 0000000..f137fe7 --- /dev/null +++ b/frontend/src/components/chat/sidebar/GroupChatAvatar.tsx @@ -0,0 +1,40 @@ +import type { Participant } from "@/types/chat" +import UserAvatar from "../shared/UserAvatar"; +import { Ellipse } from "lucide-react"; + +interface GroupChatAvatarProps { + participants: Participant[]; + type: "chat" | "sidebar"; + +} + +const GroupChatAvatar = ({ participants, type }: GroupChatAvatarProps) => { + const avatars = []; + const limit = Math.min(participants.length, 4); + + for (let i = 0; i < limit; i++) { + const member = participants[i]; + avatars.push( + + ); + } + return ( +
+ {avatars} + {/* Neu thanh vien trong nhom nhieu hon 4 thi hien thi dau 3 cham */} + {participants.length > limit && ( +
+ +
+ )} + +
+ ) +} + +export default GroupChatAvatar \ No newline at end of file diff --git a/frontend/src/components/chat/sidebar/GroupChatList.tsx b/frontend/src/components/chat/sidebar/GroupChatList.tsx new file mode 100644 index 0000000..8b61554 --- /dev/null +++ b/frontend/src/components/chat/sidebar/GroupChatList.tsx @@ -0,0 +1,25 @@ +import { useChatStore } from '@/stores/useChatStore' +import GropuMessageCard from './GropuMessageCard'; + +const GroupChatList = () => { + const { conversations } = useChatStore(); + + if (!conversations) return; + + const groupConversations = conversations.filter((convo) => convo.type === "group"); + + return ( +
+ { + groupConversations.map((convo) => ( + + )) + } +
+ ) +} + +export default GroupChatList \ No newline at end of file diff --git a/frontend/src/components/sidebar/app-sidebar.tsx b/frontend/src/components/sidebar/app-sidebar.tsx index ae4e841..bbb1bb4 100644 --- a/frontend/src/components/sidebar/app-sidebar.tsx +++ b/frontend/src/components/sidebar/app-sidebar.tsx @@ -1,8 +1,12 @@ -// import { NavUser } from "@/components/sidebar/nav-user" +import { NavUser } from "@/components/sidebar/nav-user" import { Sidebar, SidebarContent, SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, @@ -10,8 +14,18 @@ import { } from "@/components/ui/sidebar" import { Moon, Sun } from "lucide-react" import { Switch } from "../ui/switch" +import CreateNewChat from "../chat/modals/CreateNewChat" +import NewGroupChatModal from "../chat/modals/NewGroupChatModal" +import GroupChatList from "../chat/sidebar/GroupChatList" +import AddFriendModal from "../chat/modals/AddFriendModal" +import DirectMesageList from "../chat/sidebar/DirectMesageList" +import { useThemeStore } from "@/stores/useThemeStore" +import { useAuthStore } from "@/stores/useAuthStore" export function AppSidebar({ ...props }: React.ComponentProps) { + + const { isDark, toggleTheme } = useThemeStore(); + const { user } = useAuthStore(); return ( @@ -20,20 +34,21 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - -
-

Chatify

-
- - { }} - className="data-[state=checked]:bg-background/80" /> - -
+ +
+

Chatify

+
+ + +
+
+
- + @@ -41,11 +56,44 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {/* Content */} + {/* New Chat */} + + + + + + + {/* Group Chat */} + + + Nhóm Chat + + + + + + + + + + {/* Dirrect Message */} + + + Bạn bè + + + + + + + + + {/* Footer */} - {/* */} + {user && } diff --git a/frontend/src/components/sidebar/nav-user.tsx b/frontend/src/components/sidebar/nav-user.tsx index 006c150..a342bbb 100644 --- a/frontend/src/components/sidebar/nav-user.tsx +++ b/frontend/src/components/sidebar/nav-user.tsx @@ -21,16 +21,15 @@ import { useSidebar, } from "@/components/ui/sidebar" import { HugeiconsIcon } from "@hugeicons/react" -import { UnfoldMoreIcon, SparklesIcon, CheckmarkBadgeIcon, CreditCardIcon, NotificationIcon, LogoutIcon } from "@hugeicons/core-free-icons" +import { UnfoldMoreIcon } from "@hugeicons/core-free-icons" +import type { User } from "@/types/user" +import { Bell, UserIcon } from "lucide-react" +import Logout from "../auth/Logout" export function NavUser({ user, }: { - user: { - name: string - email: string - avatar: string - } + user: User }) { const { isMobile } = useSidebar() return ( @@ -43,12 +42,12 @@ export function NavUser({ } > - - CN + + {user.displayName.charAt(0)}
- {user.name} - {user.email} + {user.displayName} + {user.username}
@@ -62,42 +61,34 @@ export function NavUser({
- - CN + + {user.displayName.charAt(0)}
- {user.name} - {user.email} + {user.displayName} + {user.username}
+ + - - Upgrade to Pro - - - - - - - Account - - - - Billing + + Tài khoản - - Notifications + + Thông báo + - - - Log out + + + diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 2180169..1f0fcc9 100644 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -18,6 +18,7 @@ const buttonVariants = cva( destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", link: "text-primary underline-offset-4 hover:underline", + completeGhost:"hover:bg-transparent" }, size: { default: diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index bd0c391..0b99430 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1,6 +1,63 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } + +export const formatOnlineTime = (date: Date) => { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffMonths = Math.floor(diffDays / 30); + const diffYears = Math.floor(diffDays / 365); + + if (diffMins < 60) { + return `${diffMins}m`; // 5m, 45m + } else if (diffHours < 24) { + return `${diffHours}h`; // 3h, 20h + } else if (diffDays < 30) { + return `${diffDays}d`; // 1d, 12d + } else if (diffMonths < 12) { + return `${diffMonths}m`; // 1m, 2m, 11m + } else { + return `${diffYears}y`; // 1y, 2y + } +}; + +export const formatMessageTime = (date: Date) => { + const now = new Date(); + + const isToday = + date.getDate() === now.getDate() && + date.getMonth() === now.getMonth() && + date.getFullYear() === now.getFullYear(); + + const yesterday = new Date(); + yesterday.setDate(now.getDate() - 1); + const isYesterday = + date.getDate() === yesterday.getDate() && + date.getMonth() === yesterday.getMonth() && + date.getFullYear() === yesterday.getFullYear(); + + const timeStr = date.toLocaleTimeString("vi-VN", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + if (isToday) { + return timeStr; // ví dụ: "14:35" + } else if (isYesterday) { + return `Hôm qua ${timeStr}`; // ví dụ: "Hôm qua 23:10" + } else if (date.getFullYear() === now.getFullYear()) { + return `${date.getDate()}/${date.getMonth() + 1} ${timeStr}`; // ví dụ: "22/9 09:15" + } else { + return `${date.getDate()}/${ + date.getMonth() + 1 + }/${date.getFullYear()} ${timeStr}`; // ví dụ: "15/12/2023 18:40" + } +}; \ No newline at end of file diff --git a/frontend/src/pages/ChatAppPage.tsx b/frontend/src/pages/ChatAppPage.tsx index 14c63c1..5f90161 100644 --- a/frontend/src/pages/ChatAppPage.tsx +++ b/frontend/src/pages/ChatAppPage.tsx @@ -1,6 +1,6 @@ import { AppSidebar } from "@/components/sidebar/app-sidebar"; import { SidebarProvider } from "@/components/ui/sidebar"; -import ChatWindowLayout from "@/components/chat/ChatWindowLayout"; +import ChatWindowLayout from "@/components/chat/layout/ChatWindowLayout"; const ChatAppPage = () => { return ( diff --git a/frontend/src/services/chatService.ts b/frontend/src/services/chatService.ts new file mode 100644 index 0000000..8ab1569 --- /dev/null +++ b/frontend/src/services/chatService.ts @@ -0,0 +1,9 @@ +import api from "@/lib/axios"; +import type { ConversationResponse, Message } from "@/types/chat"; + +export const chatService = { + async fetchConversations() : Promise{ + const res = await api.get("/conversations"); + return res.data; + } +} \ No newline at end of file diff --git a/frontend/src/stores/useAuthStore.ts b/frontend/src/stores/useAuthStore.ts index 71f57fe..7fe3bba 100644 --- a/frontend/src/stores/useAuthStore.ts +++ b/frontend/src/stores/useAuthStore.ts @@ -2,108 +2,121 @@ import { create } from "zustand"; import { toast } from "sonner"; import { authService } from "@/services/authService"; import type { AuthState } from "@/types/store"; +import { persist } from "zustand/middleware"; +import { useChatStore } from "./useChatStore"; + +export const useAuthStore = create()( + persist((set, get) => ({ + accessToken: null, + user: null, + loading: false, + + setAccessToken: (accessToken) => { + set({ accessToken }); + }, + clearState: () => { + set({ accessToken: null, user: null, loading: false }); + localStorage.clear() + useChatStore.getState().reset(); + }, + + signUp: async (username, password, email, firstName, lastName) => { + try { + set({ loading: true }); + + // gọi api + await authService.signUp(username, password, email, firstName, lastName); + + toast.success("Đăng ký thành công!"); + return true; + } catch (error: any) { + console.error(error); + + const serverMessage = + error?.response?.data?.message || "Đăng ký không thành công"; + toast.error(serverMessage); + + return false; + } finally { + set({ loading: false }); + } + }, + + signIn: async (username, password) => { + try { + set({ loading: true }); + + localStorage.clear() + useChatStore.getState().reset(); + + const { accessToken } = await authService.signIn(username, password); + get().setAccessToken(accessToken); + + await get().fetchMe(); + useChatStore.getState().fetchConversatons(); -export const useAuthStore = create((set, get) => ({ - accessToken: null, - user: null, - loading: false, - - setAccessToken: (accessToken) => { - set({ accessToken }); - }, - clearState: () => { - set({ accessToken: null, user: null, loading: false }); - }, - - signUp: async (username, password, email, firstName, lastName) => { - try { - set({ loading: true }); - - // gọi api - await authService.signUp(username, password, email, firstName, lastName); - - toast.success("Đăng ký thành công!"); - return true; - } catch (error: any) { - console.error(error); - - const serverMessage = - error?.response?.data?.message || "Đăng ký không thành công"; - toast.error(serverMessage); - - return false; - } finally { - set({ loading: false }); - } - }, - - signIn: async (username, password) => { - try { - set({ loading: true }); - - const { accessToken } = await authService.signIn(username, password); - get().setAccessToken(accessToken); - - await get().fetchMe(); - - toast.success("Chào mừng bạn quay lại với Chatify!"); - return true; - } catch (error: any) { - console.error(error); - - const serverMessage = - error?.response?.data?.message || "Đăng nhập không thành công!"; - toast.error(serverMessage); - - return false; - } finally { - set({ loading: false }); - } - }, - - signOut: async () => { - try { - get().clearState(); - await authService.signOut(); - toast.success("Logout thành công!"); - } catch (error) { - console.error(error); - toast.error("Lỗi xảy ra khi logout. Hãy thử lại!"); - } - }, - - fetchMe: async () => { - try { - set({ loading: true }); - const user = await authService.fetchMe(); - - set({ user }); - } catch (error) { - console.error(error); - set({ user: null }); - toast.error("Lỗi xảy ra khi lấy dữ liệu người dùng. Hãy thử lại!"); - } finally { - set({ loading: false }); - } - }, - - refresh: async () => { - try { - set({ loading: true }); - const { user, fetchMe, setAccessToken } = get(); - const accessToken = await authService.refresh(); - - setAccessToken(accessToken); - - if (!user) { - await fetchMe(); + toast.success("Chào mừng bạn quay lại với Chatify!"); + return true; + } catch (error: any) { + console.error(error); + + const serverMessage = + error?.response?.data?.message || "Đăng nhập không thành công!"; + toast.error(serverMessage); + + return false; + } finally { + set({ loading: false }); + } + }, + + signOut: async () => { + try { + get().clearState(); + await authService.signOut(); + toast.success("Logout thành công!"); + } catch (error) { + console.error(error); + toast.error("Lỗi xảy ra khi logout. Hãy thử lại!"); + } + }, + + fetchMe: async () => { + try { + set({ loading: true }); + const user = await authService.fetchMe(); + + set({ user }); + } catch (error) { + console.error(error); + set({ user: null }); + toast.error("Lỗi xảy ra khi lấy dữ liệu người dùng. Hãy thử lại!"); + } finally { + set({ loading: false }); + } + }, + + refresh: async () => { + try { + set({ loading: true }); + const { user, fetchMe, setAccessToken } = get(); + const accessToken = await authService.refresh(); + + setAccessToken(accessToken); + + if (!user) { + await fetchMe(); + } + } catch (error) { + console.error(error); + toast.error("Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại!"); + get().clearState(); + } finally { + set({ loading: false }); } - } catch (error) { - console.error(error); - toast.error("Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại!"); - get().clearState(); - } finally { - set({ loading: false }); - } - }, -})); \ No newline at end of file + }, + }), { + name: "auth-storage", + partialize: (state) => ({ user: state.user }) + }) +); \ No newline at end of file diff --git a/frontend/src/stores/useChatStore.tsx b/frontend/src/stores/useChatStore.tsx new file mode 100644 index 0000000..051c493 --- /dev/null +++ b/frontend/src/stores/useChatStore.tsx @@ -0,0 +1,40 @@ +import { chatService } from "@/services/chatService" +import type { ChatState } from "@/types/store" +import { create } from "zustand" +import { persist } from "zustand/middleware" + +export const useChatStore = create()( + persist( + (set, get) => ({ + conversations: [], + messages: {}, + activeConversationId: null, + loading: false, + + setActiveConversation: (id) => set({ activeConversationId: id }), + + reset: () => { + set({ + conversations: [], + messages: {}, + activeConversationId: null, + loading: false, + }) + }, + + fetchConversatons: async () => { + try { + set({loading:true}); + const {conversations} = await chatService.fetchConversations(); + set({conversations,loading:false}) + } catch (error) { + console.error("Lỗi xảy ra khi fetchConversation:",error) + set({loading:false}) + } + } + }), { + name: "chat-storage", + partialize: (state) => ({ conversations: state.conversations }) + } + ) +) \ No newline at end of file diff --git a/frontend/src/stores/useThemeStore.tsx b/frontend/src/stores/useThemeStore.tsx new file mode 100644 index 0000000..e0d49ac --- /dev/null +++ b/frontend/src/stores/useThemeStore.tsx @@ -0,0 +1,34 @@ +import type { ThemeState } from "@/types/store" +import {create} from "zustand" +import {persist} from "zustand/middleware" + +export const useThemeStore = create()( + persist( + (set,get) => ({ + + isDark:false, + + toggleTheme: () => { + const newValue = !get().isDark; + set({isDark:newValue}) + if(newValue){ + document.documentElement.classList.add("dark") + }else{ + document.documentElement.classList.remove("dark") + } + }, + + setTheme:(dark:boolean) => { + set({isDark:dark}); + if(dark){ + document.documentElement.classList.add("dark") + }else{ + document.documentElement.classList.remove("dark") + } + } + }), + { + name:"theme-storage" + } + ) +) \ No newline at end of file diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts new file mode 100644 index 0000000..7a75f15 --- /dev/null +++ b/frontend/src/types/chat.ts @@ -0,0 +1,56 @@ +export interface Participant { + _id: string; + displayName: string; + avatarUrl?: string | null; + joinedAt: string; +} + +export interface SeenUser { + _id: string; + displayName?: string; + avatarUrl?: string | null; +} + +export interface Group { + name: string; + createdBy: string; +} + +export interface LastMessage { + _id: string; + content: string; + createdAt: string; + sender: { + _id: string; + displayName: string; + avatarUrl?: string | null; + }; +} + +export interface Conversation { + _id: string; + type: "direct" | "group"; + group: Group; + participants: Participant[]; + lastMessageAt: string; + seenBy: SeenUser[]; + lastMessage: LastMessage | null; + unreadCounts: Record; // key = userId, value = unread count + createdAt: string; + updatedAt: string; +} + +export interface ConversationResponse { + conversations: Conversation[]; +} + +export interface Message { + _id: string; + conversationId: string; + senderId: string; + content: string | null; + imgUrl?: string | null; + updatedAt?: string | null; + createdAt: string; + isOwn?: boolean; +} \ No newline at end of file diff --git a/frontend/src/types/store.ts b/frontend/src/types/store.ts index ef42ae3..e26e256 100644 --- a/frontend/src/types/store.ts +++ b/frontend/src/types/store.ts @@ -1,3 +1,4 @@ +import type { Conversation, Message } from "./chat"; import type { User } from "./user"; export interface AuthState { @@ -18,4 +19,25 @@ export interface AuthState { signOut: () => Promise; fetchMe: () => Promise; refresh: () => Promise; +} + +export interface ThemeState{ + isDark:boolean; + toggleTheme:() => void; + setTheme:(dark:boolean) => void; + +} + +export interface ChatState{ + conversations:Conversation[]; + messages:Record; + activeConversationId: string | null; + loading:boolean; + reset: () => void; + setActiveConversation: (id:string | null) => void; + fetchConversatons: () => Promise } \ No newline at end of file diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 2ac17d2..9edf43b 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -8,4 +8,30 @@ export interface User { phone?: string; createdAt?: string; updatedAt?: string; +} + +export interface Friend { + _id: string; + username: string; + displayName: string; + avatarUrl?: string; +} + +export interface FriendRequest { + _id: string; + from?: { + _id: string; + username: string; + displayName: string; + avatarUrl?: string; + }; + to?: { + _id: string; + username: string; + displayName: string; + avatarUrl?: string; + }; + message: string; + createdAt: string; + updatedAt: string; } \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 38a0f37..0dceb15 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { alias: { - "@": path.resolve(__dirname, "./src"), + "@": path.resolve(import.meta.dirname, "./src"), }, }, })