diff --git a/client/package-lock.json b/client/package-lock.json index 11cb999..97b0444 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -22,6 +22,7 @@ "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.3", "@reduxjs/toolkit": "^2.2.7", @@ -44,6 +45,7 @@ "react-icons": "^5.3.0", "react-markdown": "^9.0.1", "react-redux": "^9.1.2", + "react-resizable-panels": "^2.1.4", "react-router-dom": "^6.25.1", "react-wrap-balancer": "^1.1.1", "redux": "^5.0.1", @@ -1840,6 +1842,35 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", + "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toast": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.2.tgz", @@ -6364,6 +6395,15 @@ } } }, + "node_modules/react-resizable-panels": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.4.tgz", + "integrity": "sha512-kzue8lsoSBdyyd2IfXLQMMhNujOxRoGVus+63K95fQqleGxTfvgYLTzbwYMOODeAHqnkjb3WV/Ks7f5+gDYZuQ==", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-router": { "version": "6.26.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.2.tgz", diff --git a/client/package.json b/client/package.json index bbab0aa..41db929 100644 --- a/client/package.json +++ b/client/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-tooltip": "^1.1.3", "@reduxjs/toolkit": "^2.2.7", @@ -46,6 +47,7 @@ "react-icons": "^5.3.0", "react-markdown": "^9.0.1", "react-redux": "^9.1.2", + "react-resizable-panels": "^2.1.4", "react-router-dom": "^6.25.1", "react-wrap-balancer": "^1.1.1", "redux": "^5.0.1", diff --git a/client/src/App.tsx b/client/src/App.tsx index b2a0db5..420dc14 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -7,6 +7,7 @@ import Home from './pages/Home'; import Profile from './pages/Profile'; import { Toaster } from "@/components/ui/sonner"; import EditProfileForm from './pages/EditProfileForm'; +import { MessagePage } from './pages/MessagePage'; const App = () => { @@ -19,6 +20,7 @@ const App = () => { } /> } /> } /> + } /> } /> 404} /> diff --git a/client/src/components/Messages/Message.tsx b/client/src/components/Messages/Message.tsx new file mode 100644 index 0000000..1e63a15 --- /dev/null +++ b/client/src/components/Messages/Message.tsx @@ -0,0 +1,185 @@ +import { useEffect,useState } from "react" +import { + Search, +} from "lucide-react" +import { Input } from "@/components/ui/input" +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable" +import { TooltipProvider } from "@/components/ui/tooltip" +import axios from "axios" +import { Separator } from "@/components/ui/separator" +import { Textarea } from "@/components/ui/textarea" +import { Button } from "@/components/ui/button" + +const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:5000'; + +interface User { + username: string; +} + +interface ChatMessage { + sender_username: string; + message: string; +} + +export function Message() { + const [currentUserId, setCurrentUserId] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); + const [users, setUsers] = useState([]); + const [selectedUser, setSelectedUser] = useState(null); + const [message, setMessage] = useState(""); + const [chattedUsers, setChattedUsers] = useState([]); + const [chatMessages, setChatMessages] = useState([]); + + useEffect(() => { + const username = localStorage.getItem('devhub_username') || ""; + setCurrentUserId(username); + }, []); + + const handleSearch = async () => { + if (searchTerm) { + try { + const response = await axios.get(`${backendUrl}/search_users?username=${searchTerm}`); + setUsers(response.data); + } catch (error) { + console.error("Error searching users:", error); + } + } else { + setUsers([]); // Clear users if search term is empty + } + } + + const handleSendMessage = async () => { + if (selectedUser && message.trim() !== "") { + try { + await axios.post(`${backendUrl}/send_message`, { + sender_username: currentUserId, + receiver_username: selectedUser.username, + message: message + }); + setMessage(""); + fetchChatMessages(); + + if (!chattedUsers.some(user => user.username === selectedUser.username)) { + setChattedUsers([...chattedUsers, selectedUser]); + } + } catch (error) { + console.error("Error sending message:", error); + } + } + } + + const fetchChatMessages = async () => { + if (selectedUser) { + try { + const response = await axios.get(`${backendUrl}/get_messages/${selectedUser.username}`); + setChatMessages(response.data); + } catch (error) { + console.error("Error fetching messages:", error); + } + } + } + + useEffect(() => { + handleSearch(); + }, [searchTerm]); + + const handleUserSelect = (user: User) => { + setSelectedUser(user); + setUsers([]); + setChatMessages([]); + fetchChatMessages(); + } + + return ( + + { + document.cookie = `react-resizable-panels:layout:mail=${JSON.stringify(sizes)}`; + }} + className="h-full items-stretch" + > + + + { e.preventDefault(); handleSearch(); }}> + + + setSearchTerm(e.target.value)} + /> + + + + {users.map(user => ( + handleUserSelect(user)}> + {user.username} + + ))} + + + + Chatted Users + + {chattedUsers.map(user => ( + handleUserSelect(user)}> + {user.username} + + ))} + + + + + + + + {selectedUser ? ( + + Chat with {selectedUser.username} + + {chatMessages.map((msg, index) => ( + + {msg.sender_username === currentUserId ? "You" : selectedUser.username}: {msg.message} + + ))} + + + + + + setMessage(e.target.value)} + placeholder="Type a message" + /> + + + Send + + + + + + + ) : ( + + No message selected + + )} + + + + + ) +} diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx index 821f351..008b7f0 100644 --- a/client/src/components/Sidebar/Sidebar.tsx +++ b/client/src/components/Sidebar/Sidebar.tsx @@ -52,11 +52,10 @@ export default function DevhubSidebar() { export function SidebarLeft({ ...props }: React.ComponentProps) { const navigate = useNavigate(); - // Handle logout logic const handleLogout = (e: React.MouseEvent) => { - e.preventDefault(); // Prevent the default anchor behavior + e.preventDefault(); localStorage.removeItem('devhub_username'); - navigate('/login'); // Redirect to the login page + navigate('/login'); }; const sidebarLeftData = { @@ -67,10 +66,9 @@ export function SidebarLeft({ ...props }: React.ComponentProps) icon: Sparkles, }, { - title: "Inbox", - url: "#", + title: "Message", + url: "/message", icon: Inbox, - badge: "10", }, ], navSecondary: [ @@ -96,9 +94,9 @@ export function SidebarLeft({ ...props }: React.ComponentProps) }, { title: "Logout", - url: "#", // Keep the url as # for now + url: "#", icon: LogOut, - onClick: handleLogout, // Call the logout function on click + onClick: handleLogout, } ] }; diff --git a/client/src/components/ui/resizable.tsx b/client/src/components/ui/resizable.tsx new file mode 100644 index 0000000..cd3cb0e --- /dev/null +++ b/client/src/components/ui/resizable.tsx @@ -0,0 +1,43 @@ +import { GripVertical } from "lucide-react" +import * as ResizablePrimitive from "react-resizable-panels" + +import { cn } from "@/lib/utils" + +const ResizablePanelGroup = ({ + className, + ...props +}: React.ComponentProps) => ( + +) + +const ResizablePanel = ResizablePrimitive.Panel + +const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps & { + withHandle?: boolean +}) => ( + div]:rotate-90", + className + )} + {...props} + > + {withHandle && ( + + + + )} + +) + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle } diff --git a/client/src/components/ui/tabs.tsx b/client/src/components/ui/tabs.tsx new file mode 100644 index 0000000..f57fffd --- /dev/null +++ b/client/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/client/src/pages/MessagePage.tsx b/client/src/pages/MessagePage.tsx new file mode 100644 index 0000000..45dd634 --- /dev/null +++ b/client/src/pages/MessagePage.tsx @@ -0,0 +1,28 @@ +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar" +import { SidebarLeft } from '@/components/Sidebar/Sidebar' +import { Message } from "../components/Messages/Message"; + +export const MessagePage: React.FC = () => { + + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/server/api/handlers/auth/userauth.py b/server/api/handlers/auth/userauth.py index ed87170..0794096 100644 --- a/server/api/handlers/auth/userauth.py +++ b/server/api/handlers/auth/userauth.py @@ -2,7 +2,6 @@ from flask import Flask, request, jsonify, session as flask_session from extensions import bcrypt, neo4j_db, users_chat from models import User -from pymongo import MongoClient def signup(): try: diff --git a/server/api/handlers/message/message.py b/server/api/handlers/message/message.py new file mode 100644 index 0000000..d505aa6 --- /dev/null +++ b/server/api/handlers/message/message.py @@ -0,0 +1,44 @@ +from flask import request, jsonify +from models import Chat +from extensions import users_chat, chat_collection + +def save_message(sender_id, receiver_id, message): + chat = Chat(sender_id, receiver_id, message) + chat_collection.insert_one(chat.__dict__) + +def get_messages(user_id): + messages = chat_collection.find({"$or": [{"sender_id": user_id}, {"receiver_id": user_id}]}) + return list(messages) + + +def search_users(): + username = request.args.get('username') + + users = users_chat.find({"username": {"$regex": username, "$options": "i"}}) # Case-insensitive search + + return jsonify([{"username": user['username']} for user in users]) + +def send_message(): + data = request.get_json() + sender_username = data['sender_username'] + receiver_username = data['receiver_username'] + message = data['message'] + + # Fetch the sender and receiver users based on their usernames + sender = users_chat.find_one({"username": sender_username}) + receiver = users_chat.find_one({"username": receiver_username}) + + if sender and receiver: + save_message(sender['_id'], receiver['_id'], message) + return jsonify({"status": "Message sent!"}), 200 + else: + return jsonify({"error": "User not found!"}), 404 + +def get_messages(username): + user = users_chat.find_one({"username": username}) + + if user: + messages = get_messages(user['_id']) + return jsonify(messages), 200 + else: + return jsonify({"error": "User not found!"}), 404 \ No newline at end of file diff --git a/server/api/urls.py b/server/api/urls.py index 5d24bf2..cee3510 100644 --- a/server/api/urls.py +++ b/server/api/urls.py @@ -4,6 +4,7 @@ from api.handlers.analyze.leetcodedata import leetcode_data, leetcode_card from api.handlers.query.querymodel import chat,chat_history from api.handlers.user.friends import friends_bp +from api.handlers.message.message import search_users, send_message, get_messages def register_routes(app): # Authentication routes @@ -40,6 +41,11 @@ def register_routes(app): app.add_url_rule('/chat', 'chat', chat, methods=['POST']) app.add_url_rule('/chat_history', 'chat_history', chat_history, methods=['GET']) + # Messaging routes + app.add_url_rule('/search_users', 'search_users', search_users, methods=['GET']) + app.add_url_rule('/send_message', 'send_message', send_message, methods=['POST']) + app.add_url_rule('/get_messages/', 'get_messages', get_messages, methods=['GET']) + # Landing page route app.add_url_rule('/', 'index', index) diff --git a/server/app.py b/server/app.py index 680be7c..0366907 100644 --- a/server/app.py +++ b/server/app.py @@ -1,21 +1,19 @@ from flask import Flask from config import Config -from extensions import bcrypt, cors, Neo4jDriver, neo4j_db +from extensions import bcrypt, cors, Neo4jDriver import logging def create_app(): app = Flask(__name__) app.config.from_object(Config) - # Initialize Neo4j global neo4j_db neo4j_db = Neo4jDriver( app.config['NEO4J_URI'], app.config['NEO4J_USER'], app.config['NEO4J_PASSWORD'] ) - - # Initialize other extensions + bcrypt.init_app(app) cors.init_app(app, supports_credentials=True, resources={ r"/*": { diff --git a/server/extensions.py b/server/extensions.py index dda76a1..3598611 100644 --- a/server/extensions.py +++ b/server/extensions.py @@ -3,6 +3,7 @@ from neo4j import GraphDatabase from config import Config from pymongo import MongoClient +import logging bcrypt = Bcrypt() cors = CORS() @@ -20,7 +21,9 @@ def close(self): password=Config.NEO4J_PASSWORD ) -# MongoDB setup mongo_client = MongoClient(Config.MONGODB_URI) mongo_db = mongo_client['devhub'] -users_chat = mongo_db['users'] \ No newline at end of file +users_chat = mongo_db['users'] +chat_collection = mongo_db['chats'] + +logging.getLogger('pymongo').setLevel(logging.WARNING) \ No newline at end of file diff --git a/server/models.py b/server/models.py index 0683da5..a824088 100644 --- a/server/models.py +++ b/server/models.py @@ -1,16 +1,15 @@ from sqlalchemy import Column, Integer, String, Text, ForeignKey, Table from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, backref, Session +from datetime import datetime Base = declarative_base() -# Association table for the many-to-many relationship between users (friends) friend_association = Table('friend_association', Base.metadata, Column('user_id', Integer, ForeignKey('users.id')), Column('friend_id', Integer, ForeignKey('users.id')) ) -# Association table for the many-to-many relationship between projects and tags project_tags = Table('project_tags', Base.metadata, Column('project_id', Integer, ForeignKey('projects.id')), Column('tag_id', Integer, ForeignKey('tags.id')) @@ -28,10 +27,8 @@ class User(Base): github_username = Column(String, nullable=True) leetcode_username = Column(String, nullable=True) - # Establish relationship with Project projects = relationship('Project', back_populates='user') - # Establish many-to-many relationship with friends friends = relationship( 'User', secondary=friend_association, @@ -73,7 +70,6 @@ class Project(Base): user_id = Column(Integer, ForeignKey('users.id')) user = relationship('User', back_populates='projects') - # Many-to-many relationship with tags tags = relationship('Tag', secondary=project_tags, back_populates='projects') def __init__(self, title, description=None, repo_link=None): @@ -87,8 +83,16 @@ class Tag(Base): id = Column(Integer, primary_key=True) name = Column(String, unique=True, nullable=False) - # Many-to-many relationship with projects projects = relationship('Project', secondary=project_tags, back_populates='tags') def __init__(self, name): self.name = name + +class Chat: + def __init__(self, sender_id, receiver_id, message): + self.sender_id = sender_id + self.receiver_id = receiver_id + self.message = message + self.timestamp = datetime.utcnow() + +