diff --git a/backend/package-lock.json b/backend/package-lock.json index 88e23b6..c881206 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -177,6 +177,7 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -233,6 +234,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2541,6 +2543,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend/src/controllers/roomController.ts b/backend/src/controllers/roomController.ts index ad984b5..483670b 100644 --- a/backend/src/controllers/roomController.ts +++ b/backend/src/controllers/roomController.ts @@ -1,10 +1,10 @@ import type { Request, Response } from "express"; +import mongoose from "mongoose"; import Room from "../models/roomModel.js"; import type { IRoom } from "../models/roomModel.js"; -import mongoose from "mongoose"; import { io } from "../server.js"; - +// Extend Express Request with userId declare global { namespace Express { interface Request { @@ -17,17 +17,25 @@ declare global { export const createRoom = async (req: Request, res: Response) => { try { const { name } = req.body; + const userId = req.userId; + if (!name) return res.status(400).json({ message: "Room name is required" }); - if (!req.userId) return res.status(401).json({ message: "Unauthorized" }); + if (!userId) return res.status(401).json({ message: "Unauthorized" }); const existingRoom = await Room.findOne({ name }); if (existingRoom) return res.status(400).json({ message: "Room with this name already exists" }); - const room: IRoom = new Room({ name, members: [req.userId] }); + const room: IRoom = new Room({ + name, + members: [userId], + host: userId, + isActive: true, + }); + await room.save(); - io.emit("room-created", { roomId: room._id, name: room.name }); + io.emit("room-created", { roomId: room._id.toString(), name: room.name }); res.status(201).json(room); } catch (error: any) { console.error("Create room error:", error.message); @@ -38,7 +46,7 @@ export const createRoom = async (req: Request, res: Response) => { // ---------------------- List Rooms ---------------------- export const listRooms = async (_req: Request, res: Response) => { try { - const rooms = await Room.find().populate("members", "username email"); + const rooms = await Room.find({ isActive: true }).populate("members", "username email"); res.json(rooms); } catch (error: any) { console.error("List rooms error:", error.message); @@ -52,7 +60,8 @@ export const joinRoom = async (req: Request, res: Response) => { const roomIdOrName = req.params.roomIdOrName; const userId = req.userId; - if (!roomIdOrName) return res.status(400).json({ message: "Room name or ID is required" }); + if (!roomIdOrName) + return res.status(400).json({ message: "Room name or ID is required" }); if (!userId) return res.status(401).json({ message: "Unauthorized - Missing userId" }); let room = await Room.findOne({ name: roomIdOrName }); @@ -60,10 +69,7 @@ export const joinRoom = async (req: Request, res: Response) => { room = await Room.findById(roomIdOrName); } - if (!room) { - console.error("Join room error: Room not found for", roomIdOrName); - return res.status(404).json({ message: "Room not found" }); - } + if (!room) return res.status(404).json({ message: "Room not found" }); const userObjId = new mongoose.Types.ObjectId(userId); if (!room.members.some(m => m.equals(userObjId))) { @@ -72,12 +78,14 @@ export const joinRoom = async (req: Request, res: Response) => { } const updatedRoom = await Room.findById(room._id).populate("members", "username email"); - io.to(room._id.toString()).emit("user-joined", { userId, roomId: room._id }); - io.to(room._id.toString()).emit("update-members", updatedRoom?.members || []); + + const roomIdStr = room._id.toString(); + io.to(roomIdStr).emit("user-joined", { userId, roomId: roomIdStr }); + io.to(roomIdStr).emit("update-members", updatedRoom?.members || []); res.json(updatedRoom); } catch (error: any) { - console.error("Join room error:", error.message, error.stack); + console.error("Join room error:", error.message); res.status(500).json({ message: "Server error", error: error.message }); } }; @@ -87,6 +95,8 @@ export const leaveRoom = async (req: Request, res: Response) => { try { const { roomId } = req.params; const userId = req.userId; + + if (!roomId) return res.status(400).json({ message: "Missing room ID" }); if (!userId) return res.status(401).json({ message: "Unauthorized" }); const room = await Room.findById(roomId); @@ -104,7 +114,8 @@ export const leaveRoom = async (req: Request, res: Response) => { io.to(roomId).emit("update-members", updatedRoom?.members || []); if (room.members.length === 0) { - await Room.findByIdAndDelete(roomId); + room.isActive = false; + await room.save(); io.to(roomId).emit("room-ended", { roomId, reason: "empty" }); io.socketsLeave(roomId); } @@ -128,11 +139,12 @@ export const endRoom = async (req: Request, res: Response) => { const room = await Room.findById(roomId); if (!room) return res.status(404).json({ message: "Room not found" }); - const hostId = room.members[0]?.toString(); - if (hostId !== userId) + if (room.host.toString() !== userId) return res.status(403).json({ message: "Only the host can end the room" }); - await Room.findByIdAndDelete(roomId); + room.isActive = false; + await room.save(); + io.to(roomId).emit("room-ended", { roomId, endedBy: userId }); io.socketsLeave(roomId); diff --git a/backend/src/models/roomModel.ts b/backend/src/models/roomModel.ts index 9f34ec3..800461f 100644 --- a/backend/src/models/roomModel.ts +++ b/backend/src/models/roomModel.ts @@ -1,6 +1,7 @@ import mongoose, { Schema, Document } from "mongoose"; export interface IRoom extends Document { + _id: mongoose.Types.ObjectId; name: string; members: mongoose.Types.ObjectId[]; host: mongoose.Types.ObjectId; // 👈 identifies who created/owns the room diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ae6eeec..453820e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "axios": "^1.12.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.23.24", "lucide-react": "^0.545.0", "next-themes": "^0.4.6", "react": "^19.2.0", @@ -159,6 +160,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -1385,6 +1387,7 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1468,6 +1471,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -3019,6 +3023,7 @@ "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3320,6 +3325,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -4434,6 +4440,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", + "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -5125,6 +5158,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5630,6 +5678,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -5831,6 +5880,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5840,6 +5890,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6893,6 +6944,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -7197,6 +7249,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 115447e..d37ea95 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "axios": "^1.12.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.23.24", "lucide-react": "^0.545.0", "next-themes": "^0.4.6", "react": "^19.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a0ee2bb..9022d99 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,30 +10,34 @@ import OAuthSuccess from "./pages/OAuthSuccess.js"; import RoomActions from "./pages/RoomActions.js"; import ErrorBoundary from "./components/ErrorBoundary.js"; import "./index.css" -import CreateRoom from "./pages/CreateRoom.js"; import JoinRoom from "./pages/JoinRoom.js"; +import InRoom from "./pages/InRoom.js"; +import CreateRoomLobby from "./pages/CreateRoomLobby.js"; +import CreateRoom from "./pages/CreateRoom.js"; const queryClient = new QueryClient(); const App = () => ( - - - - - - - } /> - } /> - } /> - } /> - } /> - } /> {/* ✅ */} - } /> {/* ✅ */} - - - - - + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> {/* ✅ */} + } /> {/* ✅ */} + } /> + } /> + + + + + ); export default App; \ No newline at end of file diff --git a/frontend/src/pages/CreateRoom.tsx b/frontend/src/pages/CreateRoom.tsx index 5d8cf3f..ab54cb8 100644 --- a/frontend/src/pages/CreateRoom.tsx +++ b/frontend/src/pages/CreateRoom.tsx @@ -1,14 +1,14 @@ // src/pages/CreateRoom.tsx -import { useState } from "react"; +import { React, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Button } from "../components/ui/button"; +import { Button } from "../components/ui/button.js"; import axios from "axios"; export default function CreateRoom() { const [roomName, setRoomName] = useState(""); const [loading, setLoading] = useState(false); const navigate = useNavigate(); - const API_BASE = "http://localhost:3000/api"; + const API_BASE = "http://localhost:3000/api/rooms"; const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); @@ -18,7 +18,7 @@ export default function CreateRoom() { try { const token = localStorage.getItem("token"); const res = await axios.post( - `${API_BASE}/rooms/createRoom`, + `${API_BASE}/createRoom`, { name: roomName }, { headers: { Authorization: `Bearer ${token}` }, @@ -27,7 +27,8 @@ export default function CreateRoom() { ); alert("Room created successfully!"); - navigate(`/room/${res.data._id}`); // or navigate("/room-actions") + navigate(`/lobby/${res.data._id}`); + } catch (err: any) { console.error(err); alert(err.response?.data?.message || "Failed to create room."); diff --git a/frontend/src/pages/CreateRoomLobby.tsx b/frontend/src/pages/CreateRoomLobby.tsx new file mode 100644 index 0000000..b61995d --- /dev/null +++ b/frontend/src/pages/CreateRoomLobby.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import axios from "axios"; +import { Button } from "../components/ui/button.js"; + +export default function CreateRoomLobby() { + const { roomId } = useParams<{ roomId: string }>(); + const [roomName, setRoomName] = useState(""); + const [loading, setLoading] = useState(true); + const [copied, setCopied] = useState(false); + const navigate = useNavigate(); + + const API_BASE = "http://localhost:3000/api/rooms"; + + // Fetch room details by ID + useEffect(() => { + const fetchRoom = async () => { + try { + const token = localStorage.getItem("token"); + const res = await axios.get(`${API_BASE}/listRooms`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const found = res.data.find((r: any) => r._id === roomId); + if (found) setRoomName(found.name); + } catch (err) { + console.error("Failed to fetch room details:", err); + } finally { + setLoading(false); + } + }; + fetchRoom(); + }, [roomId]); + + // Copy join link to clipboard + const handleCopyLink = () => { + const link = `${window.location.origin}/room/${roomId}`; + navigator.clipboard.writeText(link); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (loading) { + return ( + + Loading room details... + + ); + } + + return ( + + + + Room Created 🎉 + + + {roomName ? `Room: ${roomName}` : "Unnamed Room"} + + + + Room ID: {roomId} + + + + {copied ? "✅ Copied!" : "Copy Join Link"} + + + + Share this link with others so they can join your room. + + + navigate(`/room/${roomId}`)} + className="mt-6 w-full bg-green-600 hover:bg-green-700 text-white font-medium py-3 rounded-lg shadow-md" + > + Go to Room + + + + ); +} diff --git a/frontend/src/pages/InRoom.tsx b/frontend/src/pages/InRoom.tsx new file mode 100644 index 0000000..c6fad6a --- /dev/null +++ b/frontend/src/pages/InRoom.tsx @@ -0,0 +1,110 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Mic, MicOff, Video, VideoOff, PhoneOff, Users, MessageSquare } from "lucide-react"; +import { motion } from "framer-motion"; + +const InRoom: React.FC<{ roomName: string }> = ({ roomName }) => { + const [micOn, setMicOn] = useState(true); + const [videoOn, setVideoOn] = useState(true); + const [showChat, setShowChat] = useState(false); + const localVideoRef = useRef(null); + const remoteVideoRef = useRef(null); + + useEffect(() => { + document.title = `${roomName} | PeerCall`; + // TODO: Setup WebRTC connection here in next step + }, [roomName]); + + return ( + + {/* Header */} + + Room: {roomName} + + + 2 Participants + + + + {/* Main Video Area */} + + {/* Video Grid */} + + + + + You + + + + + + Peer + + + + + {/* Chat Panel (toggle) */} + {showChat && ( + + In-call Chat + + No messages yet... + + + + Send + + + )} + + + {/* Controls */} + + + ); +}; + +export default InRoom; diff --git a/frontend/src/pages/JoinRoom.tsx b/frontend/src/pages/JoinRoom.tsx index 52611d0..a592e8d 100644 --- a/frontend/src/pages/JoinRoom.tsx +++ b/frontend/src/pages/JoinRoom.tsx @@ -1,13 +1,14 @@ -import { useState } from "react"; + +import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { Button } from "../components/ui/button"; +import { Button } from "../components/ui/button.js"; import axios from "axios"; export default function JoinRoom() { const [roomName, setRoomName] = useState(""); const [loading, setLoading] = useState(false); const navigate = useNavigate(); - const API_BASE = "http://localhost:3000/api/rooms"; // include /rooms here + const API_BASE = "http://localhost:3000/api/rooms";// include /rooms here const handleJoin = async (e: React.FormEvent) => { e.preventDefault(); @@ -20,14 +21,15 @@ export default function JoinRoom() { const res = await axios.post( `${API_BASE}/${roomName}/join`, - {}, // empty body + {}, { headers: { Authorization: `Bearer ${token}` }, } ); + // const roomName = res.data._id alert("Joined room successfully!"); - navigate(`/room/${res.data._id}`); + navigate(`/room/${roomName}`); } catch (err: any) { console.error("Join room error:", err); alert(err.response?.data?.message || err.message || "Failed to join room."); diff --git a/frontend/src/pages/OAuthSuccess.tsx b/frontend/src/pages/OAuthSuccess.tsx index cacc124..43a2c0f 100644 --- a/frontend/src/pages/OAuthSuccess.tsx +++ b/frontend/src/pages/OAuthSuccess.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -const API_URL = (import.meta.env.VITE_API_URL as string) || "http://localhost:3000"; +const API_URL = "http://localhost:3000"; const OAuthSuccess: React.FC = () => { const navigate = useNavigate(); @@ -46,7 +46,7 @@ const OAuthSuccess: React.FC = () => { try { const res = await fetch(`${API_URL}/api/auth/refresh`, { method: "GET", - credentials: "include", + credentials: "include", headers: { "Content-Type": "application/json", Accept: "application/json",
+ {roomName ? `Room: ${roomName}` : "Unnamed Room"} +
Room ID: {roomId}
+ Share this link with others so they can join your room. +
No messages yet...