diff --git a/.env.example b/.env.example index 12f40f07..05314972 100644 --- a/.env.example +++ b/.env.example @@ -56,4 +56,4 @@ CHANGELOG_PUBLISH_TOKEN=your_shared_secret_token AZURE_TENANT_ID= AZURE_CLIENT_ID= SMTP_SENDER=cs-tigertype@princeton.edu -SMTP_OAUTH_CACHE= (optional; JSON blob of MSAL cache for headless servers) +SMTP_OAUTH_CACHE= {"Account":{},"IdToken":{},"AccessToken":{},"RefreshToken":{},"AppMetadata":{}} \ No newline at end of file diff --git a/client/src/components/PlayerStatusBar.css b/client/src/components/PlayerStatusBar.css index c4f1c09c..0526ad86 100644 --- a/client/src/components/PlayerStatusBar.css +++ b/client/src/components/PlayerStatusBar.css @@ -9,10 +9,91 @@ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); max-height: 30vh; overflow-y: auto; + overflow-x: hidden; scrollbar-width: thin; scrollbar-color: #F58025 #1e1e1e; border: 1px solid rgba(245, 128, 37, 0.1); } + +.player-status-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + position: relative; + min-height: 44px; +} + +.player-status-title { + color: #aaa; + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.player-status-pill { + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.25rem 0.9rem; + border-radius: 10px 0 0 10px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + border: 1px solid rgba(245, 128, 37, 0.4); + background-color: rgba(245, 128, 37, 0.12); + color: var(--mode-text-color); + overflow: hidden; + transition: opacity 0.55s ease, transform 0.55s ease; + will-change: transform, opacity; + max-width: 250px; + opacity: 1; + transform: translateX(0); + transform-origin: right center; + margin-right: -1rem; + border-right: none; +} + +.player-status-pill.ready { + border-color: rgba(76, 175, 80, 0.4); + background-color: rgba(76, 175, 80, 0.12); +} + +.player-status-pill-icon { + font-size: 1rem; +} + +.player-status-pill.ready .player-status-pill-icon { + color: #4caf50; +} + +.player-status-pill.pending { + border-color: rgba(245, 128, 37, 0.4); + background-color: rgba(245, 128, 37, 0.12); +} + +.player-status-pill.pending .player-status-pill-icon { + color: #F58025; +} + +.player-status-pill.pill-hidden { + opacity: 0; + transform: translateX(120%) scaleX(0.95); + pointer-events: none; +} + +.player-status-pill.pill-visible { + opacity: 1; + transform: translateX(0) scaleX(1); +} + +.player-status-pill-text { + text-transform: uppercase; + font-size: 0.7rem; + letter-spacing: 0.08em; +} + /* Title badges for each player */ .player-titles { display: flex; @@ -48,16 +129,6 @@ border-radius: 3px; } -.player-status-bar::before { - content: "Players"; - display: block; - color: #aaa; - font-size: 0.8rem; - font-weight: 500; - margin-bottom: 0.5rem; - text-transform: uppercase; - letter-spacing: 1px; -} .player-card { width: 100%; display: flex; @@ -152,6 +223,11 @@ transition: all 0.2s; font-weight: 500; font-size: 0.85rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + min-width: 110px; } .ready-button:hover { @@ -159,6 +235,10 @@ transform: translateY(-1px); } +.ready-button.ready-standby { + background-color: #c6691b; +} + .ready-button.ready-active, .ready-button:disabled { background-color: #4caf50; @@ -167,16 +247,21 @@ } .ready-status { - padding: 0.3rem 0.6rem; + padding: 0.3rem 0.8rem; border-radius: 4px; - font-size: 0.8rem; + font-size: 0.85rem; color: #aaa; background-color: rgba(170, 170, 170, 0.1); + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 110px; + font-weight: 500; } .ready-status.ready-active { color: #4caf50; - font-weight: 600; + font-weight: 500; background-color: rgba(76, 175, 80, 0.1); } @@ -185,13 +270,41 @@ margin-top: 0.5rem; } +.progress-container.progress-has-error .progress-bar { + border: 1px solid rgba(255, 99, 132, 0.5); + box-shadow: 0 2px 8px rgba(255, 99, 132, 0.25); +} + .progress-bar { height: 20px; - background-color: #1e1e1e; + background-color: #151515; border-radius: 10px; overflow: hidden; position: relative; - box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2); + box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.progress-error-indicator { + position: absolute; + top: -1.6rem; + right: 0; + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.15rem 0.6rem; + font-size: 0.7rem; + font-weight: 600; + border-radius: 999px; + background-color: rgba(255, 99, 132, 0.2); + color: #ffb3c2; + backdrop-filter: blur(4px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); + animation: error-pill-in 0.3s ease; +} + +.progress-error-indicator .material-icons { + font-size: 1.1rem; } .progress-fill { @@ -205,6 +318,12 @@ animation: progress-bar-stripes 1s linear infinite; } +.progress-fill.has-error { + background-color: #ff5f5f; + background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.12) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.12) 50%, rgba(255, 255, 255, 0.12) 75%, transparent 75%, transparent); + animation: none; +} + @keyframes progress-bar-stripes { from { background-position: 20px 0; @@ -225,6 +344,21 @@ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); } +.progress-label-error { + color: #ffd1d1; +} + +@keyframes error-pill-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + /* Avatar Modal Styles */ .avatar-modal-overlay { position: fixed; @@ -345,4 +479,4 @@ background-color: #555; background-image: none; animation: none; -} \ No newline at end of file +} diff --git a/client/src/components/PlayerStatusBar.jsx b/client/src/components/PlayerStatusBar.jsx index e7f9e846..032f640a 100644 --- a/client/src/components/PlayerStatusBar.jsx +++ b/client/src/components/PlayerStatusBar.jsx @@ -1,17 +1,32 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import './PlayerStatusBar.css'; import defaultProfileImage from '../assets/icons/default-profile.svg'; import { useAuth } from '../context/AuthContext'; import axios from 'axios'; import ProfileModal from './ProfileModal.jsx'; -function PlayerStatusBar({ players, isRaceInProgress, currentUser, onReadyClick }) { +function PlayerStatusBar({ + players, + isRaceInProgress, + currentUser, + onReadyClick, + countdownActive = false, + waitingForMinimumPlayers = false, + readinessSummary = null, + readinessDetail = null +}) { const [enlargedAvatar, setEnlargedAvatar] = useState(null); const { authenticated, user } = useAuth(); const [selectedProfileNetid, setSelectedProfileNetid] = useState(null); const [showProfileModal, setShowProfileModal] = useState(false); // State for storing fetched titles per player netid const [playerTitlesMap, setPlayerTitlesMap] = useState({}); + const [pillState, setPillState] = useState(null); + const [pillVisible, setPillVisible] = useState(false); + const pillHideTimeout = useRef(null); + const pillEntryTimeout = useRef(null); + const pillMountedRef = useRef(false); + const showProgressBars = isRaceInProgress; // For debug // console.log("PlayerStatusBar - isRaceInProgress:", isRaceInProgress); @@ -77,11 +92,118 @@ function PlayerStatusBar({ players, isRaceInProgress, currentUser, onReadyClick }); }, [players, user, playerTitlesMap]); + useEffect(() => { + return () => { + if (pillHideTimeout.current) { + clearTimeout(pillHideTimeout.current); + pillHideTimeout.current = null; + } + if (pillEntryTimeout.current) { + clearTimeout(pillEntryTimeout.current); + pillEntryTimeout.current = null; + } + }; + }, []); + + useEffect(() => { + const shouldShowPill = !!readinessSummary && !countdownActive; + + if (!shouldShowPill) { + if (!pillState) { + return; + } + + if (pillEntryTimeout.current) { + clearTimeout(pillEntryTimeout.current); + pillEntryTimeout.current = null; + } + + if (pillHideTimeout.current) { + clearTimeout(pillHideTimeout.current); + pillHideTimeout.current = null; + } + + setPillVisible(false); + pillHideTimeout.current = setTimeout(() => { + setPillState(null); + pillMountedRef.current = false; + pillHideTimeout.current = null; + }, 600); + return; + } + + const nextState = { + summary: readinessSummary, + detail: readinessDetail || '', + pending: waitingForMinimumPlayers + }; + + setPillState((prev) => { + if ( + prev && + prev.summary === nextState.summary && + prev.detail === nextState.detail && + prev.pending === nextState.pending + ) { + return prev; + } + return nextState; + }); + + if (pillHideTimeout.current) { + clearTimeout(pillHideTimeout.current); + pillHideTimeout.current = null; + } + + const isFirstMount = !pillMountedRef.current; + pillMountedRef.current = true; + + if (pillVisible) { + return; + } + + if (pillEntryTimeout.current) { + clearTimeout(pillEntryTimeout.current); + pillEntryTimeout.current = null; + } + + pillEntryTimeout.current = setTimeout(() => { + setPillVisible(true); + pillEntryTimeout.current = null; + }, isFirstMount ? 120 : 40); + }, [readinessSummary, readinessDetail, waitingForMinimumPlayers, countdownActive]); + return ( <>
+
+ Players +
+ {pillState && ( + + + {pillState.pending ? 'groups' : 'check_circle'} + + + {pillState.summary} + + + )} +
+
{players.map((player, index) => { const isDisconnected = !!player.disconnected; + const isCurrentUser = player.netid === currentUser?.netid; + const hasMistake = !!player.hasMistake; + const primaryLabel = player.ready ? 'Ready' : 'Ready Up'; + const readyHint = waitingForMinimumPlayers + ? 'Need at least two players before the countdown begins' + : 'Race starts when everyone readies up'; return (
- {player.ready ? 'Ready' : 'Ready Up'} + {primaryLabel} ) : ( @@ -154,14 +278,20 @@ function PlayerStatusBar({ players, isRaceInProgress, currentUser, onReadyClick ) : null}
- {isRaceInProgress && ( -
+ {showProgressBars && ( +
+ {hasMistake && ( +
+ + Fix errors +
+ )}
-
+
{isDisconnected ? 'DC' : `${player.progress || 0}%`}
@@ -213,4 +343,4 @@ function PlayerStatusBar({ players, isRaceInProgress, currentUser, onReadyClick ); } -export default PlayerStatusBar; \ No newline at end of file +export default PlayerStatusBar; diff --git a/client/src/components/Typing.css b/client/src/components/Typing.css index 25109e0b..e3cd4282 100644 --- a/client/src/components/Typing.css +++ b/client/src/components/Typing.css @@ -3,6 +3,7 @@ inset: 0px; min-height: 5rem; flex-direction: column; + padding-bottom: 2rem; } .stats-container { @@ -275,6 +276,34 @@ box-shadow: 0 2px 8px rgba(255, 65, 47, 0.4); } +.typing-error-banner { + position: absolute; + left: 50%; + right: auto; + bottom: 0; + transform: translate(-50%, 100%); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + padding: 0.45rem 1rem; + border-radius: 999px; + background: rgba(255, 65, 47, 0.88); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); + color: #fff; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.03em; + pointer-events: none; + animation: bannerPulse 2s ease-in-out infinite; + width: fit-content; + white-space: nowrap; +} + +.typing-error-banner .material-icons { + font-size: 1rem; +} + /* Caps Lock Warning styles */ /* Caps Lock warning — banner above snippet */ .caps-lock-warning { @@ -334,6 +363,11 @@ 100% { opacity: 0; margin-top: 0; } } +@keyframes bannerPulse { + 0%, 100% { opacity: 0.9; transform: translate(-50%, 10%) scale(1); } + 50% { opacity: 1; transform: translate(-50%, 10%) scale(1.05); } +} + /* The next-to-type character */ .current { position: relative; diff --git a/client/src/components/Typing.jsx b/client/src/components/Typing.jsx index 3c65e603..6a56fd99 100644 --- a/client/src/components/Typing.jsx +++ b/client/src/components/Typing.jsx @@ -1152,6 +1152,12 @@ function Typing({ */} {getHighlightedText()}
+ {showErrorMessage && ( +
+ + Fix your mistake to continue +
+ )}
)} - {/* Render warnings */} - {showErrorMessage && ( -
- Fix your mistake to continue -
- )} - {renderPracticeTooltip()} ); diff --git a/client/src/context/RaceContext.jsx b/client/src/context/RaceContext.jsx index de13d98c..e0cd2377 100644 --- a/client/src/context/RaceContext.jsx +++ b/client/src/context/RaceContext.jsx @@ -320,7 +320,8 @@ export const RaceProvider = ({ children }) => { ...player, progress: data.percentage, position: data.position, - completed: data.completed + completed: data.completed, + hasMistake: !!data.hasError }; } return player; @@ -773,6 +774,7 @@ export const RaceProvider = ({ children }) => { // Check if all characters are typed correctly for completion const isCompleted = input.length === text.length && !hasError; + const progressPosition = Math.min(correctChars, text.length); // Find the last completely correct word boundary before any error (snippet mode only) let newLockedPosition = 0; @@ -831,9 +833,10 @@ export const RaceProvider = ({ children }) => { if (socket && connected) { socket.emit('race:progress', { code: raceState.code, - position: input.length, + position: progressPosition, total: text.length, - isCompleted: isCompleted // Send explicit completion status to server + isCompleted, + hasError }); } diff --git a/client/src/pages/Race.jsx b/client/src/pages/Race.jsx index 30256a3c..512dad59 100644 --- a/client/src/pages/Race.jsx +++ b/client/src/pages/Race.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useRace } from '../context/RaceContext'; import { useTutorial } from '../context/TutorialContext'; @@ -13,6 +13,8 @@ import Leaderboard from '../components/Leaderboard'; import TutorialAnchor from '../components/TutorialAnchor'; import './Race.css'; +const MIN_PLAYERS_FOR_PUBLIC_RACE = 2; + function Race() { const navigate = useNavigate(); const { socket } = useSocket(); @@ -43,6 +45,23 @@ function Race() { const practiceResultIndex = tutorialSteps.practice.findIndex(s => s.id === 'practice-results-screen'); const [showLeaderboard, setShowLeaderboard] = useState(false); + const players = raceState.players || []; + const playersJoined = players.length; + const readyCount = players.reduce((count, player) => (player.ready ? count + 1 : count), 0); + const playersNeededToStart = Math.max(0, MIN_PLAYERS_FOR_PUBLIC_RACE - playersJoined); + const waitingForMinimumPlayers = playersNeededToStart > 0; + const countdownActive = raceState.countdown !== null; + const shouldShowLobbyStatus = raceState.type === 'public' && !raceState.inProgress && !raceState.completed && !countdownActive; + const targetReadyCount = MIN_PLAYERS_FOR_PUBLIC_RACE; + const displayedReady = Math.min(readyCount, targetReadyCount); + const readinessSummary = shouldShowLobbyStatus + ? `Ready ${displayedReady}/${targetReadyCount}` + : null; + const readinessDetail = shouldShowLobbyStatus + ? (waitingForMinimumPlayers + ? `Need ${playersNeededToStart} more racer${playersNeededToStart === 1 ? '' : 's'} to launch` + : 'Minimum met. Waiting for everyone to ready up.') + : null; // If there is no active race context, redirect to home useEffect(() => { @@ -175,12 +194,16 @@ function Race() {
{/* Player Status Bar (Only relevant for multiplayer and when race is not completed) */} - {raceState.players && raceState.players.length > 0 && raceState.type !== 'practice' && !raceState.completed && ( + {players.length > 0 && raceState.type !== 'practice' && !raceState.completed && ( )}
diff --git a/server/controllers/socket-handlers.js b/server/controllers/socket-handlers.js index 72ea6c7a..da7596dd 100644 --- a/server/controllers/socket-handlers.js +++ b/server/controllers/socket-handlers.js @@ -1320,7 +1320,7 @@ const initialize = (io) => { socket.on('race:progress', (data) => { try { // Client sends { position, total, isCompleted } - const { code, position, isCompleted } = data; + const { code, position, isCompleted, hasError = false } = data; // Check if race exists and is active const race = activeRaces.get(code); @@ -1362,6 +1362,7 @@ const initialize = (io) => { playerProgress.set(socket.id, { position, completed: isCompleted, // Use the client-provided completion status + hasError: !!hasError, timestamp: now }); @@ -1373,7 +1374,8 @@ const initialize = (io) => { netid, position, percentage, - completed: isCompleted // Use the client-provided completion status + completed: isCompleted, + hasError: !!hasError }); // Handle race completion for this player if they just completed