restored everything after nuking the repo

This commit is contained in:
QkoSad
2025-07-20 22:22:47 +03:00
commit 8ff6e07fba
49 changed files with 7825 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+4019
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.10.0",
"lucide-react": "^0.525.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hot-toast": "^2.5.2",
"react-router-dom": "^7.7.0",
"socket.io-client": "^4.8.1",
"zustand": "^5.0.6"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@tailwindcss/vite": "^4.1.11",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"daisyui": "^5.0.46",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"tailwindcss": "^4.1.11",
"vite": "^7.0.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+60
View File
@@ -0,0 +1,60 @@
import Navbar from "./components/Navbar";
import HomePage from "./pages/HomePage";
import SignUpPage from "./pages/SignUpPage";
import LoginPage from "./pages/LoginPage";
import SettingsPage from "./pages/SettingsPage";
import ProfilePage from "./pages/ProfilePage";
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuthStore } from "./store/useAuthStore";
import { useThemeStore } from "./store/useThemeStore";
import { useEffect } from "react";
import { Loader } from "lucide-react";
import { Toaster } from "react-hot-toast";
const App = () => {
const { authUser, checkAuth, isCheckingAuth, onlineUsers } = useAuthStore();
const { theme } = useThemeStore();
useEffect(() => {
checkAuth();
}, [checkAuth]);
if (isCheckingAuth && !authUser)
return (
<div className="flex items-center justify-center h-screen">
<Loader className="size-10 animate-spin" />
</div>
);
return (
<div data-theme={theme}>
<Navbar />
<Routes>
<Route
path="/"
element={authUser ? <HomePage /> : <Navigate to="/login" />}
/>
<Route
path="/signup"
element={!authUser ? <SignUpPage /> : <Navigate to="/" />}
/>
<Route
path="/login"
element={!authUser ? <LoginPage /> : <Navigate to="/" />}
/>
<Route path="/settings" element={<SettingsPage />} />
<Route
path="/profile"
element={authUser ? <ProfilePage /> : <Navigate to="/login" />}
/>
</Routes>
<Toaster />
</div>
);
};
export default App;
@@ -0,0 +1,22 @@
const AuthImagePattern = ({ title, subtitle }) => {
return (
<div className="hidden lg:flex items-center justify-center bg-base-200 p-12">
<div className="max-w-md text-center">
<div className="grid grid-cols-3 gap-3 mb-8">
{[...Array(9)].map((_, i) => (
<div
key={i}
className={`aspect-square rounded-2xl bg-primary/10 ${
i % 2 === 0 ? "animate-pulse" : ""
}`}
/>
))}
</div>
<h2 className="text-2xl font-bold mb-4">{title}</h2>
<p className="text-base-content/60">{subtitle}</p>
</div>
</div>
);
};
export default AuthImagePattern;
+92
View File
@@ -0,0 +1,92 @@
import { useChatStore } from "../store/useChatStore";
import { useEffect, useRef } from "react";
import ChatHeader from "./ChatHeader";
import MessageInput from "./MessageInput";
import MessageSkeleton from "./skeletons/MessageSkeleton";
import { useAuthStore } from "../store/useAuthStore";
import { formatMessageTime } from "../lib/utils";
const ChatContainer = () => {
const {
messages,
getMessages,
isMessagesLoading,
selectedUser,
subscribeToMessages,
unsubscribeFromMessages,
} = useChatStore();
const { authUser } = useAuthStore();
const messageEndRef = useRef(null);
useEffect(() => {
getMessages(selectedUser._id);
subscribeToMessages();
return () => unsubscribeFromMessages();
}, [selectedUser._id, getMessages, subscribeToMessages, unsubscribeFromMessages]);
useEffect(() => {
if (messageEndRef.current && messages) {
messageEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
if (isMessagesLoading) {
return (
<div className="flex-1 flex flex-col overflow-auto">
<ChatHeader />
<MessageSkeleton />
<MessageInput />
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-auto">
<ChatHeader />
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((message) => (
<div
key={message._id}
className={`chat ${message.senderId === authUser._id ? "chat-end" : "chat-start"}`}
ref={messageEndRef}
>
<div className=" chat-image avatar">
<div className="size-10 rounded-full border">
<img
src={
message.senderId === authUser._id
? authUser.profilePic || "/avatar.png"
: selectedUser.profilePic || "/avatar.png"
}
alt="profile pic"
/>
</div>
</div>
<div className="chat-header mb-1">
<time className="text-xs opacity-50 ml-1">
{formatMessageTime(message.createdAt)}
</time>
</div>
<div className="chat-bubble flex flex-col">
{message.image && (
<img
src={message.image}
alt="Attachment"
className="sm:max-w-[200px] rounded-md mb-2"
/>
)}
{message.text && <p>{message.text}</p>}
</div>
</div>
))}
</div>
<MessageInput />
</div>
);
};
export default ChatContainer;
+37
View File
@@ -0,0 +1,37 @@
import { X } from "lucide-react";
import { useAuthStore } from "../store/useAuthStore";
import { useChatStore } from "../store/useChatStore";
const ChatHeader = () => {
const { selectedUser, setSelectedUser } = useChatStore();
const { onlineUsers } = useAuthStore();
return (
<div className="p-2.5 border-b border-base-300">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Avatar */}
<div className="avatar">
<div className="size-10 rounded-full relative">
<img src={selectedUser.profilePic || "/avatar.png"} alt={selectedUser.fullName} />
</div>
</div>
{/* User info */}
<div>
<h3 className="font-medium">{selectedUser.fullName}</h3>
<p className="text-sm text-base-content/70">
{onlineUsers.includes(selectedUser._id) ? "Online" : "Offline"}
</p>
</div>
</div>
{/* Close button */}
<button onClick={() => setSelectedUser(null)}>
<X />
</button>
</div>
</div>
);
};
export default ChatHeader;
+109
View File
@@ -0,0 +1,109 @@
import { useRef, useState } from "react";
import { useChatStore } from "../store/useChatStore";
import { Image, Send, X } from "lucide-react";
import toast from "react-hot-toast";
const MessageInput = () => {
const [text, setText] = useState("");
const [imagePreview, setImagePreview] = useState(null);
const fileInputRef = useRef(null);
const { sendMessage } = useChatStore();
const handleImageChange = (e) => {
const file = e.target.files[0];
if (!file.type.startsWith("image/")) {
toast.error("Please select an image file");
return;
}
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result);
};
reader.readAsDataURL(file);
};
const removeImage = () => {
setImagePreview(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleSendMessage = async (e) => {
e.preventDefault();
if (!text.trim() && !imagePreview) return;
try {
await sendMessage({
text: text.trim(),
image: imagePreview,
});
// Clear form
setText("");
setImagePreview(null);
if (fileInputRef.current) fileInputRef.current.value = "";
} catch (error) {
console.error("Failed to send message:", error);
}
};
return (
<div className="p-4 w-full">
{imagePreview && (
<div className="mb-3 flex items-center gap-2">
<div className="relative">
<img
src={imagePreview}
alt="Preview"
className="w-20 h-20 object-cover rounded-lg border border-zinc-700"
/>
<button
onClick={removeImage}
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-base-300
flex items-center justify-center"
type="button"
>
<X className="size-3" />
</button>
</div>
</div>
)}
<form onSubmit={handleSendMessage} className="flex items-center gap-2">
<div className="flex-1 flex gap-2">
<input
type="text"
className="w-full input input-bordered rounded-lg input-sm sm:input-md"
placeholder="Type a message..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
<input
type="file"
accept="image/*"
className="hidden"
ref={fileInputRef}
onChange={handleImageChange}
/>
<button
type="button"
className={`hidden sm:flex btn btn-circle
${imagePreview ? "text-emerald-500" : "text-zinc-400"}`}
onClick={() => fileInputRef.current?.click()}
>
<Image size={20} />
</button>
</div>
<button
type="submit"
className="btn btn-sm btn-circle"
disabled={!text.trim() && !imagePreview}
>
<Send size={22} />
</button>
</form>
</div>
);
};
export default MessageInput;
+55
View File
@@ -0,0 +1,55 @@
import { Link } from "react-router-dom";
import { useAuthStore } from "../store/useAuthStore";
import { LogOut, MessageSquare, Settings, User } from "lucide-react";
const Navbar = () => {
const { logout, authUser } = useAuthStore();
return (
<header
className="bg-base-100 border-b border-base-300 fixed w-full top-0 z-40
backdrop-blur-lg bg-base-100/80"
>
<div className="container mx-auto px-4 h-16">
<div className="flex items-center justify-between h-full">
<div className="flex items-center gap-8">
<Link to="/" className="flex items-center gap-2.5 hover:opacity-80 transition-all">
<div className="size-9 rounded-lg bg-primary/10 flex items-center justify-center">
<MessageSquare className="w-5 h-5 text-primary" />
</div>
<h1 className="text-lg font-bold">Chatty</h1>
</Link>
</div>
<div className="flex items-center gap-2">
<Link
to={"/settings"}
className={`
btn btn-sm gap-2 transition-colors
`}
>
<Settings className="w-4 h-4" />
<span className="hidden sm:inline">Settings</span>
</Link>
{authUser && (
<>
<Link to={"/profile"} className={`btn btn-sm gap-2`}>
<User className="size-5" />
<span className="hidden sm:inline">Profile</span>
</Link>
<button className="flex gap-2 items-center" onClick={logout}>
<LogOut className="size-5" />
<span className="hidden sm:inline">Logout</span>
</button>
</>
)}
</div>
</div>
</div>
</header>
);
};
export default Navbar;
@@ -0,0 +1,29 @@
import { MessageSquare } from "lucide-react";
const NoChatSelected = () => {
return (
<div className="w-full flex flex-1 flex-col items-center justify-center p-16 bg-base-100/50">
<div className="max-w-md text-center space-y-6">
{/* Icon Display */}
<div className="flex justify-center gap-4 mb-4">
<div className="relative">
<div
className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center
justify-center animate-bounce"
>
<MessageSquare className="w-8 h-8 text-primary " />
</div>
</div>
</div>
{/* Welcome Text */}
<h2 className="text-2xl font-bold">Welcome to Chatty!</h2>
<p className="text-base-content/60">
Select a conversation from the sidebar to start chatting
</p>
</div>
</div>
);
};
export default NoChatSelected;
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import { useChatStore } from "../store/useChatStore";
import { useAuthStore } from "../store/useAuthStore";
import SidebarSkeleton from "./skeletons/SidebarSkeleton";
import { Users } from "lucide-react";
const Sidebar = () => {
const { getUsers, users, selectedUser, setSelectedUser, isUsersLoading } = useChatStore();
const { onlineUsers } = useAuthStore();
const [showOnlineOnly, setShowOnlineOnly] = useState(false);
useEffect(() => {
getUsers();
}, [getUsers]);
const filteredUsers = showOnlineOnly
? users.filter((user) => onlineUsers.includes(user._id))
: users;
if (isUsersLoading) return <SidebarSkeleton />;
return (
<aside className="h-full w-20 lg:w-72 border-r border-base-300 flex flex-col transition-all duration-200">
<div className="border-b border-base-300 w-full p-5">
<div className="flex items-center gap-2">
<Users className="size-6" />
<span className="font-medium hidden lg:block">Contacts</span>
</div>
{/* TODO: Online filter toggle */}
<div className="mt-3 hidden lg:flex items-center gap-2">
<label className="cursor-pointer flex items-center gap-2">
<input
type="checkbox"
checked={showOnlineOnly}
onChange={(e) => setShowOnlineOnly(e.target.checked)}
className="checkbox checkbox-sm"
/>
<span className="text-sm">Show online only</span>
</label>
<span className="text-xs text-zinc-500">({onlineUsers.length - 1} online)</span>
</div>
</div>
<div className="overflow-y-auto w-full py-3">
{filteredUsers.map((user) => (
<button
key={user._id}
onClick={() => setSelectedUser(user)}
className={`
w-full p-3 flex items-center gap-3
hover:bg-base-300 transition-colors
${selectedUser?._id === user._id ? "bg-base-300 ring-1 ring-base-300" : ""}
`}
>
<div className="relative mx-auto lg:mx-0">
<img
src={user.profilePic || "/avatar.png"}
alt={user.name}
className="size-12 object-cover rounded-full"
/>
{onlineUsers.includes(user._id) && (
<span
className="absolute bottom-0 right-0 size-3 bg-green-500
rounded-full ring-2 ring-zinc-900"
/>
)}
</div>
{/* User info - only visible on larger screens */}
<div className="hidden lg:block text-left min-w-0">
<div className="font-medium truncate">{user.fullName}</div>
<div className="text-sm text-zinc-400">
{onlineUsers.includes(user._id) ? "Online" : "Offline"}
</div>
</div>
</button>
))}
{filteredUsers.length === 0 && (
<div className="text-center text-zinc-500 py-4">No online users</div>
)}
</div>
</aside>
);
};
export default Sidebar;
@@ -0,0 +1,28 @@
const MessageSkeleton = () => {
// Create an array of 6 items for skeleton messages
const skeletonMessages = Array(6).fill(null);
return (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{skeletonMessages.map((_, idx) => (
<div key={idx} className={`chat ${idx % 2 === 0 ? "chat-start" : "chat-end"}`}>
<div className="chat-image avatar">
<div className="size-10 rounded-full">
<div className="skeleton w-full h-full rounded-full" />
</div>
</div>
<div className="chat-header mb-1">
<div className="skeleton h-4 w-16" />
</div>
<div className="chat-bubble bg-transparent p-0">
<div className="skeleton h-16 w-[200px]" />
</div>
</div>
))}
</div>
);
};
export default MessageSkeleton;
@@ -0,0 +1,41 @@
import { Users } from "lucide-react";
const SidebarSkeleton = () => {
// Create 8 skeleton items
const skeletonContacts = Array(8).fill(null);
return (
<aside
className="h-full w-20 lg:w-72 border-r border-base-300
flex flex-col transition-all duration-200"
>
{/* Header */}
<div className="border-b border-base-300 w-full p-5">
<div className="flex items-center gap-2">
<Users className="w-6 h-6" />
<span className="font-medium hidden lg:block">Contacts</span>
</div>
</div>
{/* Skeleton Contacts */}
<div className="overflow-y-auto w-full py-3">
{skeletonContacts.map((_, idx) => (
<div key={idx} className="w-full p-3 flex items-center gap-3">
{/* Avatar skeleton */}
<div className="relative mx-auto lg:mx-0">
<div className="skeleton size-12 rounded-full" />
</div>
{/* User info skeleton - only visible on larger screens */}
<div className="hidden lg:block text-left min-w-0 flex-1">
<div className="skeleton h-4 w-32 mb-2" />
<div className="skeleton h-3 w-16" />
</div>
</div>
))}
</div>
</aside>
);
};
export default SidebarSkeleton;
+34
View File
@@ -0,0 +1,34 @@
export const THEMES = [
"light",
"dark",
"cupcake",
"bumblebee",
"emerald",
"corporate",
"synthwave",
"retro",
"cyberpunk",
"valentine",
"halloween",
"garden",
"forest",
"aqua",
"lofi",
"pastel",
"fantasy",
"wireframe",
"black",
"luxury",
"dracula",
"cmyk",
"autumn",
"business",
"acid",
"lemonade",
"night",
"coffee",
"winter",
"dim",
"nord",
"sunset",
];
+8
View File
@@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@plugin "daisyui" {
themes: all;
}
+6
View File
@@ -0,0 +1,6 @@
import axios from "axios";
export const axiosInstance = axios.create({
baseURL: import.meta.env.MODE === "development" ? "http://localhost:5001/api" : "/api",
withCredentials: true,
});
+7
View File
@@ -0,0 +1,7 @@
export function formatMessageTime(date) {
return new Date(date).toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.jsx";
import { BrowserRouter } from "react-router-dom";
createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
+24
View File
@@ -0,0 +1,24 @@
import { useChatStore } from "../store/useChatStore";
import Sidebar from "../components/Sidebar";
import NoChatSelected from "../components/NoChatSelected";
import ChatContainer from "../components/ChatContainer";
const HomePage = () => {
const { selectedUser } = useChatStore();
return (
<div className="h-screen bg-base-200">
<div className="flex items-center justify-center pt-20 px-4">
<div className="bg-base-100 rounded-lg shadow-cl w-full max-w-6xl h-[calc(100vh-8rem)]">
<div className="flex h-full rounded-lg overflow-hidden">
<Sidebar />
{!selectedUser ? <NoChatSelected /> : <ChatContainer />}
</div>
</div>
</div>
</div>
);
};
export default HomePage;
+119
View File
@@ -0,0 +1,119 @@
import { useState } from "react";
import { useAuthStore } from "../store/useAuthStore";
import AuthImagePattern from "../components/AuthImagePattern";
import { Link } from "react-router-dom";
import { Eye, EyeOff, Loader2, Lock, Mail, MessageSquare } from "lucide-react";
const LoginPage = () => {
const [showPassword, setShowPassword] = useState(false);
const [formData, setFormData] = useState({
email: "",
password: "",
});
const { login, isLoggingIn } = useAuthStore();
const handleSubmit = async (e) => {
e.preventDefault();
login(formData);
};
return (
<div className="h-screen grid lg:grid-cols-2">
{/* Left Side - Form */}
<div className="flex flex-col justify-center items-center p-6 sm:p-12">
<div className="w-full max-w-md space-y-8">
{/* Logo */}
<div className="text-center mb-8">
<div className="flex flex-col items-center gap-2 group">
<div
className="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center group-hover:bg-primary/20
transition-colors"
>
<MessageSquare className="w-6 h-6 text-primary" />
</div>
<h1 className="text-2xl font-bold mt-2">Welcome Back</h1>
<p className="text-base-content/60">Sign in to your account</p>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Email</span>
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-base-content/40" />
</div>
<input
type="email"
className={`input input-bordered w-full pl-10`}
placeholder="you@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Password</span>
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-base-content/40" />
</div>
<input
type={showPassword ? "text" : "password"}
className={`input input-bordered w-full pl-10`}
placeholder="••••••••"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-base-content/40" />
) : (
<Eye className="h-5 w-5 text-base-content/40" />
)}
</button>
</div>
</div>
<button type="submit" className="btn btn-primary w-full" disabled={isLoggingIn}>
{isLoggingIn ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Loading...
</>
) : (
"Sign in"
)}
</button>
</form>
<div className="text-center">
<p className="text-base-content/60">
Don&apos;t have an account?{" "}
<Link to="/signup" className="link link-primary">
Create account
</Link>
</p>
</div>
</div>
</div>
{/* Right Side - Image/Pattern */}
<AuthImagePattern
title={"Welcome back!"}
subtitle={"Sign in to continue your conversations and catch up with your messages."}
/>
</div>
);
};
export default LoginPage;
+101
View File
@@ -0,0 +1,101 @@
import { useState } from "react";
import { useAuthStore } from "../store/useAuthStore";
import { Camera, Mail, User } from "lucide-react";
const ProfilePage = () => {
const { authUser, isUpdatingProfile, updateProfile } = useAuthStore();
const handleImageUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
updateProfile(file);
};
return (
<div className="h-screen pt-20">
<div className="max-w-2xl mx-auto p-4 py-8">
<div className="bg-base-300 rounded-xl p-6 space-y-8">
<div className="text-center">
<h1 className="text-2xl font-semibold ">Profile</h1>
<p className="mt-2">Your profile information</p>
</div>
{/* avatar upload section */}
<div className="flex flex-col items-center gap-4">
<div className="relative">
<img
src={authUser.profilePic || "/avatar.png"}
alt="Profile"
className="size-32 rounded-full object-cover border-4 "
/>
<label
htmlFor="avatar-upload"
className={`
absolute bottom-0 right-0
bg-base-content hover:scale-105
p-2 rounded-full cursor-pointer
transition-all duration-200
${isUpdatingProfile ? "animate-pulse pointer-events-none" : ""}
`}
>
<Camera className="w-5 h-5 text-base-200" />
<input
type="file"
id="avatar-upload"
className="hidden"
accept="image/*"
onChange={handleImageUpload}
disabled={isUpdatingProfile}
/>
</label>
</div>
<p className="text-sm text-zinc-400">
{isUpdatingProfile
? "Uploading..."
: "Click the camera icon to update your photo"}
</p>
</div>
<div className="space-y-6">
<div className="space-y-1.5">
<div className="text-sm text-zinc-400 flex items-center gap-2">
<User className="w-4 h-4" />
Full Name
</div>
<p className="px-4 py-2.5 bg-base-200 rounded-lg border">
{authUser?.fullName}
</p>
</div>
<div className="space-y-1.5">
<div className="text-sm text-zinc-400 flex items-center gap-2">
<Mail className="w-4 h-4" />
Email Address
</div>
<p className="px-4 py-2.5 bg-base-200 rounded-lg border">
{authUser?.email}
</p>
</div>
</div>
<div className="mt-6 bg-base-300 rounded-xl p-6">
<h2 className="text-lg font-medium mb-4">Account Information</h2>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between py-2 border-b border-zinc-700">
<span>Member Since</span>
<span>{authUser.createdAt?.split("T")[0]}</span>
</div>
<div className="flex items-center justify-between py-2">
<span>Account Status</span>
<span className="text-green-500">Active</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ProfilePage;
+116
View File
@@ -0,0 +1,116 @@
import { THEMES } from "../constants";
import { useThemeStore } from "../store/useThemeStore";
import { Send } from "lucide-react";
const PREVIEW_MESSAGES = [
{ id: 1, content: "Hey! How's it going?", isSent: false },
{ id: 2, content: "I'm doing great! Just working on some new features.", isSent: true },
];
const SettingsPage = () => {
const { theme, setTheme } = useThemeStore();
return (
<div className="h-screen container mx-auto px-4 pt-20 max-w-5xl">
<div className="space-y-6">
<div className="flex flex-col gap-1">
<h2 className="text-lg font-semibold">Theme</h2>
<p className="text-sm text-base-content/70">Choose a theme for your chat interface</p>
</div>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
{THEMES.map((t) => (
<button
key={t}
className={`
group flex flex-col items-center gap-1.5 p-2 rounded-lg transition-colors
${theme === t ? "bg-base-200" : "hover:bg-base-200/50"}
`}
onClick={() => setTheme(t)}
>
<div className="relative h-8 w-full rounded-md overflow-hidden" data-theme={t}>
<div className="absolute inset-0 grid grid-cols-4 gap-px p-1">
<div className="rounded bg-primary"></div>
<div className="rounded bg-secondary"></div>
<div className="rounded bg-accent"></div>
<div className="rounded bg-neutral"></div>
</div>
</div>
<span className="text-[11px] font-medium truncate w-full text-center">
{t.charAt(0).toUpperCase() + t.slice(1)}
</span>
</button>
))}
</div>
{/* Preview Section */}
<h3 className="text-lg font-semibold mb-3">Preview</h3>
<div className="rounded-xl border border-base-300 overflow-hidden bg-base-100 shadow-lg">
<div className="p-4 bg-base-200">
<div className="max-w-lg mx-auto">
{/* Mock Chat UI */}
<div className="bg-base-100 rounded-xl shadow-sm overflow-hidden">
{/* Chat Header */}
<div className="px-4 py-3 border-b border-base-300 bg-base-100">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center text-primary-content font-medium">
J
</div>
<div>
<h3 className="font-medium text-sm">John Doe</h3>
<p className="text-xs text-base-content/70">Online</p>
</div>
</div>
</div>
{/* Chat Messages */}
<div className="p-4 space-y-4 min-h-[200px] max-h-[200px] overflow-y-auto bg-base-100">
{PREVIEW_MESSAGES.map((message) => (
<div
key={message.id}
className={`flex ${message.isSent ? "justify-end" : "justify-start"}`}
>
<div
className={`
max-w-[80%] rounded-xl p-3 shadow-sm
${message.isSent ? "bg-primary text-primary-content" : "bg-base-200"}
`}
>
<p className="text-sm">{message.content}</p>
<p
className={`
text-[10px] mt-1.5
${message.isSent ? "text-primary-content/70" : "text-base-content/70"}
`}
>
12:00 PM
</p>
</div>
</div>
))}
</div>
{/* Chat Input */}
<div className="p-4 border-t border-base-300 bg-base-100">
<div className="flex gap-2">
<input
type="text"
className="input input-bordered flex-1 text-sm h-10"
placeholder="Type a message..."
value="This is a preview"
readOnly
/>
<button className="btn btn-primary h-10 min-h-0">
<Send size={18} />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default SettingsPage;
+154
View File
@@ -0,0 +1,154 @@
import { useState } from "react";
import { useAuthStore } from "../store/useAuthStore";
import { Eye, EyeOff, Loader2, Lock, Mail, MessageSquare, User } from "lucide-react";
import { Link } from "react-router-dom";
import AuthImagePattern from "../components/AuthImagePattern";
import toast from "react-hot-toast";
const SignUpPage = () => {
const [showPassword, setShowPassword] = useState(false);
const [formData, setFormData] = useState({
fullName: "",
email: "",
password: "",
});
const { signup, isSigningUp } = useAuthStore();
const validateForm = () => {
if (!formData.fullName.trim()) return toast.error("Full name is required");
if (!formData.email.trim()) return toast.error("Email is required");
if (!/\S+@\S+\.\S+/.test(formData.email)) return toast.error("Invalid email format");
if (!formData.password) return toast.error("Password is required");
if (formData.password.length < 6) return toast.error("Password must be at least 6 characters");
return true;
};
const handleSubmit = (e) => {
e.preventDefault();
const success = validateForm();
if (success === true) signup(formData);
};
return (
<div className="min-h-screen grid lg:grid-cols-2">
{/* left side */}
<div className="flex flex-col justify-center items-center p-6 sm:p-12">
<div className="w-full max-w-md space-y-8">
{/* LOGO */}
<div className="text-center mb-8">
<div className="flex flex-col items-center gap-2 group">
<div
className="size-12 rounded-xl bg-primary/10 flex items-center justify-center
group-hover:bg-primary/20 transition-colors"
>
<MessageSquare className="size-6 text-primary" />
</div>
<h1 className="text-2xl font-bold mt-2">Create Account</h1>
<p className="text-base-content/60">Get started with your free account</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Full Name</span>
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="size-5 text-base-content/40" />
</div>
<input
type="text"
className={`input input-bordered w-full pl-10`}
placeholder="John Doe"
value={formData.fullName}
onChange={(e) => setFormData({ ...formData, fullName: e.target.value })}
/>
</div>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Email</span>
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="size-5 text-base-content/40" />
</div>
<input
type="email"
className={`input input-bordered w-full pl-10`}
placeholder="you@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-medium">Password</span>
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="size-5 text-base-content/40" />
</div>
<input
type={showPassword ? "text" : "password"}
className={`input input-bordered w-full pl-10`}
placeholder="••••••••"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="size-5 text-base-content/40" />
) : (
<Eye className="size-5 text-base-content/40" />
)}
</button>
</div>
</div>
<button type="submit" className="btn btn-primary w-full" disabled={isSigningUp}>
{isSigningUp ? (
<>
<Loader2 className="size-5 animate-spin" />
Loading...
</>
) : (
"Create Account"
)}
</button>
</form>
<div className="text-center">
<p className="text-base-content/60">
Already have an account?{" "}
<Link to="/login" className="link link-primary">
Sign in
</Link>
</p>
</div>
</div>
</div>
{/* right side */}
<AuthImagePattern
title="Join our community"
subtitle="Connect with friends, share moments, and stay in touch with your loved ones."
/>
</div>
);
};
export default SignUpPage;
+114
View File
@@ -0,0 +1,114 @@
import { create } from "zustand";
import { axiosInstance } from "../lib/axios.js";
import toast from "react-hot-toast";
import { io } from "socket.io-client";
const BASE_URL =
import.meta.env.MODE === "development" ? "http://localhost:5001" : "/";
export const useAuthStore = create((set, get) => ({
authUser: null,
isSigningUp: false,
isLoggingIn: false,
isUpdatingProfile: false,
isCheckingAuth: true,
onlineUsers: [],
socket: null,
checkAuth: async () => {
try {
const res = await axiosInstance.get("/auth/check");
set({ authUser: res.data });
get().connectSocket();
} catch (error) {
console.log("Error in checkAuth:", error);
set({ authUser: null });
} finally {
set({ isCheckingAuth: false });
}
},
signup: async (data) => {
set({ isSigningUp: true });
try {
const res = await axiosInstance.post("/auth/signup", data);
set({ authUser: res.data });
toast.success("Account created successfully");
get().connectSocket();
} catch (error) {
toast.error(error.response.data.message);
} finally {
set({ isSigningUp: false });
}
},
login: async (data) => {
set({ isLoggingIn: true });
try {
const res = await axiosInstance.post("/auth/login", data);
set({ authUser: res.data });
toast.success("Logged in successfully");
get().connectSocket();
} catch (error) {
toast.error(error.response.data.message);
} finally {
set({ isLoggingIn: false });
}
},
logout: async () => {
try {
await axiosInstance.post("/auth/logout");
set({ authUser: null });
toast.success("Logged out successfully");
get().disconnectSocket();
} catch (error) {
toast.error(error.response.data.message);
}
},
updateProfile: async (data) => {
set({ isUpdatingProfile: true });
// try {
const formData = new FormData();
formData.append("image", data);
const res = await axiosInstance.put("/auth/update-profile", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
console.log(res.data);
set({ authUser: res.data });
toast.success("Profile updated successfully");
// } catch (error) {
// console.log("error in update profile:", error);
// toast.error(error.response.data.message);
// } finally {
set({ isUpdatingProfile: false });
// }
},
connectSocket: () => {
const { authUser } = get();
if (!authUser || get().socket?.connected) return;
const socket = io(BASE_URL, {
query: {
userId: authUser._id,
},
});
socket.connect();
set({ socket: socket });
socket.on("getOnlineUsers", (userIds) => {
set({ onlineUsers: userIds });
});
},
disconnectSocket: () => {
if (get().socket?.connected) get().socket.disconnect();
},
}));
+68
View File
@@ -0,0 +1,68 @@
import { create } from "zustand";
import toast from "react-hot-toast";
import { axiosInstance } from "../lib/axios";
import { useAuthStore } from "./useAuthStore";
export const useChatStore = create((set, get) => ({
messages: [],
users: [],
selectedUser: null,
isUsersLoading: false,
isMessagesLoading: false,
getUsers: async () => {
set({ isUsersLoading: true });
try {
const res = await axiosInstance.get("/messages/users");
set({ users: res.data });
} catch (error) {
toast.error(error.response.data.message);
} finally {
set({ isUsersLoading: false });
}
},
getMessages: async (userId) => {
set({ isMessagesLoading: true });
try {
const res = await axiosInstance.get(`/messages/${userId}`);
set({ messages: res.data });
} catch (error) {
toast.error(error.response.data.message);
} finally {
set({ isMessagesLoading: false });
}
},
sendMessage: async (messageData) => {
const { selectedUser, messages } = get();
try {
const res = await axiosInstance.post(`/messages/send/${selectedUser._id}`, messageData);
set({ messages: [...messages, res.data] });
} catch (error) {
toast.error(error.response.data.message);
}
},
subscribeToMessages: () => {
const { selectedUser } = get();
if (!selectedUser) return;
const socket = useAuthStore.getState().socket;
socket.on("newMessage", (newMessage) => {
const isMessageSentFromSelectedUser = newMessage.senderId === selectedUser._id;
if (!isMessageSentFromSelectedUser) return;
set({
messages: [...get().messages, newMessage],
});
});
},
unsubscribeFromMessages: () => {
const socket = useAuthStore.getState().socket;
socket.off("newMessage");
},
setSelectedUser: (selectedUser) => set({ selectedUser }),
}));
+9
View File
@@ -0,0 +1,9 @@
import { create } from "zustand";
export const useThemeStore = create((set) => ({
theme: localStorage.getItem("chat-theme") || "coffee",
setTheme: (theme) => {
localStorage.setItem("chat-theme", theme);
set({ theme });
},
}));
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
});