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) : ""}
+
+
+
+
+
+ )
+}
+
+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" />
-
-
+
+
+
-
+
@@ -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"),
},
},
})