diff --git a/backend/index.js b/backend/index.js index ad12f78..5c75e29 100644 --- a/backend/index.js +++ b/backend/index.js @@ -12,7 +12,7 @@ import { resetUser, setUser, } from "./user.js"; -import { setFinishers } from "./race.js"; +import { getWinner, resetFinishers, resetWinner, setFinishers } from "./race.js"; import dotenv from "dotenv"; dotenv.config(); @@ -111,12 +111,33 @@ io.on("connection", (socket) => { createRoom(userName, room, settings) ); socket.on("joinRoom", (userName, room) => joinRoom(userName, room)); + socket.on("leaveRoom", () => { + const room = Array.from(socket.rooms).find((id) => id !== socket.id); + removeUser(socket.id); + if (room) { + socket.leave(room); + const remainingUsers = getUsersInRoom(room); + io.to(room).emit("playerList", { + players: remainingUsers, + winnerId: getWinner(room), + }); + if (remainingUsers.length === 0) { + roomSettings.delete(room); + resetWinner(room); + } + emitLobbyStats(); + } + socket.emit("leftRoom"); + }); socket.on("typing", (user) => { console.log(`[INFO] User ${user.name} typing in room ${user.room} - Progress: ${user.progress}%`); setUser(user); const playerList = getUsersInRoom(user.room); - io.to(user.room).emit("playerList", playerList); + io.to(user.room).emit("playerList", { + players: playerList, + winnerId: getWinner(user.room), + }); }); function createRoom(userName, room, settings) { @@ -141,7 +162,10 @@ io.on("connection", (socket) => { addUser(tempUser); const playerList = getUsersInRoom(roomId); - io.to(roomId).emit("playerList", playerList); + io.to(roomId).emit("playerList", { + players: playerList, + winnerId: getWinner(roomId), + }); emitLobbyStats(); } @@ -149,8 +173,13 @@ io.on("connection", (socket) => { console.log(`[INFO] Fetching new text for room: ${room}`); const settings = roomSettings.get(room) || normalizeRoomSettings(); getText(io, room, settings); + resetFinishers(room); + resetWinner(room); const playerList = resetUser(room); - io.to(room).emit("playerList", playerList); + io.to(room).emit("playerList", { + players: playerList, + winnerId: getWinner(room), + }); }); function joinRoom(userName, roomName) { @@ -191,7 +220,10 @@ io.on("connection", (socket) => { }; addUser(tempUser); const playerList = getUsersInRoom(roomName); - io.to(roomName).emit("playerList", playerList); + io.to(roomName).emit("playerList", { + players: playerList, + winnerId: getWinner(roomName), + }); emitLobbyStats(); if (numClients === 1) { @@ -207,6 +239,11 @@ io.on("connection", (socket) => { socket.on("finished", (user) => { console.log(`[INFO] User ${user.name} finished race in room ${user.room} - Speed: ${user.speed} WPM`); setFinishers(user, io); + const playerList = getUsersInRoom(user.room); + io.to(user.room).emit("playerList", { + players: playerList, + winnerId: getWinner(user.room), + }); }); socket.on("disconnect", () => { @@ -214,9 +251,13 @@ io.on("connection", (socket) => { if (user) { console.log(`[INFO] User ${user.name} disconnected from room: ${user.room}`); const remainingUsers = getUsersInRoom(user.room); - io.to(user.room).emit("playerList", remainingUsers); + io.to(user.room).emit("playerList", { + players: remainingUsers, + winnerId: getWinner(user.room), + }); if (remainingUsers.length === 0) { roomSettings.delete(user.room); + resetWinner(user.room); } emitLobbyStats(); } else { diff --git a/backend/race.js b/backend/race.js index 264e41b..ba7a583 100644 --- a/backend/race.js +++ b/backend/race.js @@ -1,12 +1,17 @@ import { getUsersInRoom } from "./user.js"; const finishers = new Map(); +const winners = new Map(); export function setFinishers(user, io) { let players = finishers.get(user.room); if (players) players.push(user); else players = new Array(user); + if (!winners.has(user.room)) { + winners.set(user.room, user.id); + } + let totalUsers = getUsersInRoom(user.room); console.log(`[INFO] Finisher count: ${players.length}/${totalUsers.length} in room ${user.room}`); @@ -33,6 +38,14 @@ export function getFinishers(room) { return finishers.get(room); } +export function getWinner(room) { + return winners.get(room) || null; +} + export function resetFinishers(room) { finishers.set(room, []); } + +export function resetWinner(room) { + winners.delete(room); +} diff --git a/frontend/index.html b/frontend/index.html index eb83665..7b6c849 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -110,6 +110,7 @@
+
@@ -258,6 +259,9 @@
📋 🔗 +
diff --git a/frontend/public/style.css b/frontend/public/style.css index 7d87f2e..05b4ee4 100644 --- a/frontend/public/style.css +++ b/frontend/public/style.css @@ -503,6 +503,29 @@ header svg { color: var(--mp-panel-accent); } +.leaveRoomBtn { + border-radius: 8px; + border: 1px solid var(--mp-panel-border); + background: var(--mp-panel-input-bg); + color: var(--font-color); + font-size: 0.65rem; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 0.35rem 0.65rem; + cursor: pointer; + opacity: 0.65; + transition: 150ms ease; +} + +.leaveRoomBtn:hover { + opacity: 1; + border-color: var(--mp-panel-accent); +} + +.leaveRoomBtn:active { + transform: translate(-0.5px, 1px); +} + .roomSettings { display: flex; flex-direction: column; @@ -1177,12 +1200,14 @@ footer { height: 250px; background-color: inherit; color: inherit; + overflow: hidden; } .stats .bg { width: 100%; position: absolute; z-index: 0; + height: 100%; } .statsText { diff --git a/frontend/src/firebase/auth.js b/frontend/src/firebase/auth.js index 158fd39..70ef45b 100644 --- a/frontend/src/firebase/auth.js +++ b/frontend/src/firebase/auth.js @@ -31,8 +31,10 @@ if (isDevelopment) export const signIn = async ({ email, password }) => { try { const userCred = await signInWithEmailAndPassword(auth, email, password); + return { ok: true, userCred }; } catch (error) { alert(error.code); + return { ok: false, error }; } }; @@ -59,8 +61,10 @@ export const signUp = async ({ email, password, username }) => { }; setUserData(data); }); + return { ok: true, userCred }; } catch (error) { alert(error.code); + return { ok: false, error }; } }; @@ -76,15 +80,30 @@ export const logout = () => { export const authState = () => { onAuthStateChanged(auth, async (user) => { if (user) { - user = await getUserData(); - if (user && user.wordCount == null) { - const updatedUser = { ...user, wordCount: DEFAULT_WORD_COUNT }; + let userData = await getUserData(); + if (!userData) { + const displayName = + user.displayName || + (user.email ? user.email.split("@")[0] : "player"); + userData = { + uId: user.uid, + userName: displayName, + topSpeed: 0, + theme: defaultTheme, + punctuationMode, + smallCaseMode, + wordCount: DEFAULT_WORD_COUNT, + lastSpeed: 0, + }; + setUserData(userData); + } else if (userData.wordCount == null) { + const updatedUser = { ...userData, wordCount: DEFAULT_WORD_COUNT }; setUserData(updatedUser); - user = updatedUser; + userData = updatedUser; } - handleMenu(user); - handleStats(user); - username.innerText = user.userName; + handleMenu(userData); + handleStats(userData); + username.innerText = userData.userName || ""; } else { handleMenu(user); handleStats(user); diff --git a/frontend/src/socket/roomHandling.js b/frontend/src/socket/roomHandling.js index 7465c6a..3fa038f 100644 --- a/frontend/src/socket/roomHandling.js +++ b/frontend/src/socket/roomHandling.js @@ -36,7 +36,7 @@ export async function createRoom() { setIsHost(true); } -export function renderPlayers(playerList) { +export function renderPlayers(playerList, winnerId = null) { const list = Array.isArray(playerList) ? playerList : []; if (list.length === 0) { clearPlayerArea(); @@ -46,7 +46,7 @@ export function renderPlayers(playerList) { return; } - const winnerKey = getWinnerKey(list); + const winnerKey = getWinnerKey(list, winnerId); renderTrackPlayers(list, winnerKey); renderLobbyPlayers(list, winnerKey); announceWinnerIfAny(list, winnerKey); @@ -77,14 +77,17 @@ function renderTrackPlayers(playerList, winnerKey) { } row.innerHTML = '
' + - crownSvg + '
'; requestAnimationFrame(() => { row.classList.remove("is-new"); }); } - row.classList.toggle("is-winner", key === winnerKey); + if (key === winnerKey) { + row.classList.add("is-winner"); + } else { + row.classList.remove("is-winner"); + } const nameContainer = row.querySelector(".playerName"); const nameEl = row.querySelector(".playerNameText"); @@ -99,6 +102,7 @@ function renderTrackPlayers(playerList, winnerKey) { const progress = Number(player.progress) || 0; if (nameContainer) { + syncCrown(nameContainer, key === winnerKey); nameContainer.style.left = `${progress}%`; nameContainer.style.transform = `translateX(-${progress}%)`; } @@ -146,7 +150,7 @@ function renderLobbyPlayers(playerList, winnerKey) {
-
${crownSvg}
+
@@ -165,13 +169,21 @@ function renderLobbyPlayers(playerList, winnerKey) { const speed = player.speed === undefined || player.speed === null ? 0 : player.speed; - row.classList.toggle("is-winner", key === winnerKey); + if (key === winnerKey) { + row.classList.add("is-winner"); + } else { + row.classList.remove("is-winner"); + } const nameEl = row.querySelector(".lobbyPlayerNameText"); + const nameContainer = row.querySelector(".lobbyPlayerName"); if (nameEl) { nameEl.textContent = name; } else { row.querySelector(".lobbyPlayerName").textContent = name; } + if (nameContainer) { + syncCrown(nameContainer, key === winnerKey); + } row.querySelector(".lobbyPlayerAvatar").textContent = getInitials(name); row.querySelector(".lobbyPlayerTag").textContent = getPlayerTag(player, key); row.querySelector(".lobbyPlayerSpeed").textContent = `Speed: ${speed}`; @@ -199,8 +211,9 @@ function clearLobbyPlayers() { } function getPlayerKey(player, index) { - const keyBase = player.id || player.name || `player-${index}`; - return String(keyBase); + if (player && player.id) return String(player.id); + const namePart = player && player.name ? String(player.name) : "player"; + return `${namePart}-${index}`; } function getPlayerName(player, index) { @@ -221,6 +234,18 @@ function getPlayerTag(player, fallbackKey) { return `#${String(fallbackKey).slice(0, 4)}`; } +function syncCrown(container, isWinner) { + if (!container) return; + const existing = container.querySelector(".playerCrown"); + if (isWinner) { + if (!existing) { + container.insertAdjacentHTML("afterbegin", crownSvg); + } + } else if (existing) { + existing.remove(); + } +} + function announceWinnerIfAny(playerList, winnerKey) { if (!winnerKey || winnerAnnounced) return; @@ -237,12 +262,20 @@ function announceWinnerIfAny(playerList, winnerKey) { handlePopup(`${getPlayerName(winner, winnerIndex)} wins!`, 2500); } -function getWinnerKey(playerList) { - const winnerIndex = playerList.findIndex( +function getWinnerKey(playerList, winnerId) { + if (winnerId) { + const winnerIndex = playerList.findIndex( + (player) => player && player.id === winnerId + ); + if (winnerIndex !== -1) { + return getPlayerKey(playerList[winnerIndex], winnerIndex); + } + } + const fallbackIndex = playerList.findIndex( (player) => (Number(player.progress) || 0) >= 100 ); - if (winnerIndex === -1) return null; - return getPlayerKey(playerList[winnerIndex], winnerIndex); + if (fallbackIndex === -1) return null; + return getPlayerKey(playerList[fallbackIndex], fallbackIndex); } export function resetWinnerState() { diff --git a/frontend/src/socket/socket.js b/frontend/src/socket/socket.js index 1a56df3..86fa9fa 100644 --- a/frontend/src/socket/socket.js +++ b/frontend/src/socket/socket.js @@ -10,6 +10,8 @@ import { roomSettingsList, roomHeader, textContainer, + lobbyPlayersWrap, + roomId, } from "../ui/uiElements"; import { isHost, @@ -40,6 +42,7 @@ socket.on("connect", () => { }); socket.on("disconnect", () => { + resetLobbyUI(); setIsHost(false); setMultiplayerMode(false); console.log("you are disconnected"); @@ -75,15 +78,20 @@ socket.on("unknownCode", () => { socket.on("tooManyPlayers", () => { handlePopup("room is full 😞", 1000); }); +socket.on("leftRoom", () => { + resetLobbyUI(); +}); let you, showTimer, newTextTimeout; -socket.on("playerList", (playerList) => { +socket.on("playerList", (payload) => { + const playerList = Array.isArray(payload) ? payload : payload.players || []; + const winnerId = Array.isArray(payload) ? null : payload.winnerId || null; let index = playerList.findIndex((user) => user.id == socket.id); if (index !== -1) { you = playerList[index]; } - renderPlayers(playerList); + renderPlayers(playerList, winnerId); }); socket.on("newText", (data) => { @@ -151,6 +159,20 @@ function closeMpArea() { }, 500); } +function resetLobbyUI() { + setIsHost(false); + setMultiplayerMode(false); + resetWinnerState(); + renderPlayers([]); + if (joinRoomForm) joinRoomForm.classList.remove("hide"); + if (lobbyStats) lobbyStats.classList.remove("hide"); + if (roomHeader) roomHeader.classList.add("hide"); + if (roomSettings) roomSettings.classList.add("hide"); + if (roomSettingsList) roomSettingsList.innerHTML = ""; + if (lobbyPlayersWrap) lobbyPlayersWrap.classList.add("hide"); + if (roomId) roomId.textContent = ""; +} + function renderRoomSettings(settings) { if (!roomSettings || !roomSettingsList) return; const chips = []; @@ -177,4 +199,9 @@ function renderRoomSettings(settings) { } } +export function leaveRoom() { + resetLobbyUI(); + socket.emit("leaveRoom"); +} + export default socket; diff --git a/frontend/src/ui/uiElements.js b/frontend/src/ui/uiElements.js index 88dbda3..ccf8781 100644 --- a/frontend/src/ui/uiElements.js +++ b/frontend/src/ui/uiElements.js @@ -49,6 +49,7 @@ export const loginBtn = document.querySelector(".loginBtn"); export const signinForm = document.querySelector(".profile>.routs>.signIn"); export const updateBtn = document.querySelector(".updateBtn"); export const updateForm = document.querySelector(".profile>.routs>.update"); +export const updateCancel = document.querySelector(".updateCancel"); //logout export const logoutBtn = document.querySelector(".logoutBtn"); @@ -90,6 +91,7 @@ export const playersCount = document.querySelector(".playersCount"); export const roomHeader = document.querySelector(".roomHeader"); export const copyRoomId = document.querySelector(".copyRoomId"); export const copyRoomLink = document.querySelector(".copyRoomLink"); +export const leaveRoomBtn = document.querySelector(".leaveRoomBtn"); export const roomId = document.querySelector("span.roomId"); export const mpClose = document.querySelector(".mpClose"); export const lobbyPlayersWrap = document.querySelector(".lobbyPlayersWrap"); diff --git a/frontend/src/ui/uiListeners.js b/frontend/src/ui/uiListeners.js index 72ded70..0dd5307 100644 --- a/frontend/src/ui/uiListeners.js +++ b/frontend/src/ui/uiListeners.js @@ -26,6 +26,7 @@ import { updateBtn, signUpinfo, loader, + updateCancel, leaderBoardBtn, leaderBoard, updateForm, @@ -40,6 +41,7 @@ import { copyRoomLink, roomId, mpClose, + leaveRoomBtn, } from "./uiElements"; import saveStats from "../functions/saveStats"; import { logout, signIn, signUp, updateUser } from "../firebase/auth"; @@ -51,6 +53,7 @@ import { normalizeWordCount, } from "../functions/userDefault"; import { createRoom, joinRoom } from "../socket/roomHandling"; +import { leaveRoom } from "../socket/socket"; import handlePopup from "../functions/handlePopup"; import { multiplayerMode } from "../functions/userDefault"; import { handleInput } from "../functions/start"; @@ -233,20 +236,29 @@ saveStatsBtn.addEventListener("click", () => { }); //signupform and signIn form handler -signupForm.addEventListener("submit", (e) => { +signupForm.addEventListener("submit", async (e) => { e.preventDefault(); - signUp({ + handleProfile(loader); + const result = await signUp({ email: e.target.email.value, username: e.target.username.value, password: e.target.password.value, }); - handleProfile(loader); + if (!result || !result.ok) { + handleProfile(signupForm); + } }); -signinForm.addEventListener("submit", (e) => { +signinForm.addEventListener("submit", async (e) => { e.preventDefault(); - signIn({ email: e.target.email.value, password: e.target.password.value }); handleProfile(loader); + const result = await signIn({ + email: e.target.email.value, + password: e.target.password.value, + }); + if (!result || !result.ok) { + handleProfile(signinForm); + } }); // to show signup and login window @@ -268,6 +280,12 @@ updateBtn.addEventListener("click", () => { handleProfile(updateForm); }); +if (updateCancel) { + updateCancel.addEventListener("click", () => { + handleProfile(stats); + }); +} + updateForm.addEventListener("submit", (e) => { e.preventDefault(); let username = e.target.username.value; @@ -376,6 +394,12 @@ if (mpClose) { }); } +if (leaveRoomBtn) { + leaveRoomBtn.addEventListener("click", () => { + leaveRoom(); + }); +} + //create room createRoomBtn.addEventListener("click", (e) => { createRoom();