Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions client/src/components/Leaderboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ h2 {
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem; /* Spacing between groups */
gap: 1.5rem;
margin-bottom: 2rem;
padding: 0.75rem;
background-color: rgba(20, 20, 20, 0.3); /* Darker background */
Expand Down Expand Up @@ -107,7 +107,12 @@ h2 {
min-width: 150px; /* Ensure player name area has space */
}

/* Make more of the entry clickable when authenticated */
.leaderboard-player-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
}

.leaderboard-player.clickable { cursor: pointer; }
.leaderboard-player.clickable:hover .leaderboard-netid {
color: #F58025;
Expand Down Expand Up @@ -153,9 +158,26 @@ h2 {
transition: color 0.2s ease, text-shadow 0.2s ease;
}

/* Title badges for leaderboard entries */
.leaderboard-titles {
display: flex;
align-items: center;
margin-top: 0.25rem;
}

.leaderboard-title-badge {
font-size: 0.75rem;
font-weight: 500;
color: #F58025;
border-left: 4px solid #F58025;
padding-left: 6px;
background-color: rgba(245, 128, 37, 0.1);
border-radius: 4px;
}

.leaderboard-stats {
display: flex;
gap: 1.5rem; /* Increased gap */
gap: 1.5rem;
margin-left: auto;
align-items: baseline;
flex-shrink: 0; /* Prevent stats from shrinking too much */
Expand Down Expand Up @@ -201,7 +223,7 @@ h2 {
}

.spinner-border.text-orange {
color: #F58025 !important; /* Ensure orange color */
color: #F58025 !important;
width: 3rem;
height: 3rem;
}
Expand Down
166 changes: 122 additions & 44 deletions client/src/components/Leaderboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { useSocket } from '../context/SocketContext';
import { useAuth } from '../context/AuthContext';
import PropTypes from 'prop-types';
import axios from 'axios';
import './Leaderboard.css';
import defaultProfileImage from '../assets/icons/default-profile.svg';
import ProfileModal from './ProfileModal.jsx';
Expand Down Expand Up @@ -29,6 +30,22 @@ const formatRelativeTime = (timestamp) => {
return createdAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
};

// Shallow comparison for arrays of title objects; assumes stable ordering
const titlesAreEqual = (a, b) => {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const left = a[i];
const right = b[i];
if (!left || !right) return false;
if (left.id !== right.id || left.name !== right.name || left.is_equipped !== right.is_equipped) {
return false;
}
}
return true;
};

function Leaderboard({ defaultDuration = 15, defaultPeriod = 'alltime', layoutMode = 'modal' }) {
const { socket } = useSocket();
// Destructure authenticated flag to check CAS login status
Expand All @@ -46,6 +63,10 @@ function Leaderboard({ defaultDuration = 15, defaultPeriod = 'alltime', layoutMo
const [selectedProfileNetid, setSelectedProfileNetid] = useState(null);
const [showProfileModal, setShowProfileModal] = useState(false);
const [isMobile, setIsMobile] = useState(false);
// State for storing fetched titles per leaderboard entry
const [leaderboardTitlesMap, setLeaderboardTitlesMap] = useState({});
// Ref to track which netids we've initiated fetches for (to avoid duplicate requests)
const fetchingRef = useRef(new Set());

// Track viewport to switch to compact controls on small screens
useEffect(() => {
Expand Down Expand Up @@ -138,6 +159,85 @@ function Leaderboard({ defaultDuration = 15, defaultPeriod = 'alltime', layoutMo

}, [socket, duration, period]);

// Fetch titles for each leaderboard entry
useEffect(() => {
if (!authenticated || leaderboard.length === 0) {
// Clear titles map and fetching ref when not authenticated or leaderboard is empty
setLeaderboardTitlesMap(prev => {
if (Object.keys(prev).length === 0) return prev;
return {};
});
fetchingRef.current.clear();
return;
}

// Get current netids in leaderboard
const currentNetids = new Set(leaderboard.map(entry => entry.netid));

// Clean up titles and fetching ref for users no longer in leaderboard
setLeaderboardTitlesMap(prev => {
let didRemoveEntry = false;
const cleanedEntries = Object.entries(prev).filter(([netid]) => {
const shouldKeep = currentNetids.has(netid);
if (!shouldKeep) {
didRemoveEntry = true;
}
return shouldKeep;
});

// Clean up fetching ref regardless of whether map entries changed
const filteredFetching = new Set();
fetchingRef.current.forEach(netid => {
if (currentNetids.has(netid)) {
filteredFetching.add(netid);
}
});
fetchingRef.current = filteredFetching;

if (!didRemoveEntry) {
return prev;
}

return Object.fromEntries(cleanedEntries);
});

leaderboard.forEach(entry => {
const netid = entry.netid;

// Sync context titles for current user
if (netid === user?.netid && user?.titles) {
setLeaderboardTitlesMap(prev => {
const existingTitles = prev[netid];
if (titlesAreEqual(existingTitles, user.titles)) return prev; // No change needed
return { ...prev, [netid]: user.titles };
});
}
// Fetch other users' titles if not already fetched or currently fetching
if (netid !== user?.netid && !(netid in leaderboardTitlesMap) && !fetchingRef.current.has(netid)) {
fetchingRef.current.add(netid);
axios.get(`/api/user/${netid}/titles`)
.then(res => {
const nextTitles = res.data || [];
setLeaderboardTitlesMap(prev => {
const existingTitles = prev[netid];
if (titlesAreEqual(existingTitles, nextTitles)) return prev;
return { ...prev, [netid]: nextTitles };
});
fetchingRef.current.delete(netid);
})
.catch(err => {
console.error(`Error fetching titles for ${netid}:`, err);
setLeaderboardTitlesMap(prev => {
const existingTitles = prev[netid];
if (Array.isArray(existingTitles) && existingTitles.length === 0) return prev;
return { ...prev, [netid]: [] };
});
fetchingRef.current.delete(netid);
});
}
});
}, [leaderboard, user, authenticated, leaderboardTitlesMap]);
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEffect has leaderboardTitlesMap in its dependency array, but the effect also updates leaderboardTitlesMap. This creates an infinite re-render loop. Remove leaderboardTitlesMap from the dependencies array since the effect checks !(netid in leaderboardTitlesMap) which will see the updated state correctly without it being a dependency.

Suggested change
}, [leaderboard, user, authenticated, leaderboardTitlesMap]);
}, [leaderboard, user, authenticated]);

Copilot uses AI. Check for mistakes.

const handleAvatarClick = (_avatarUrl, netid) => {
// Only proceed if user is authenticated via CAS
if (!authenticated) return;
Expand Down Expand Up @@ -205,40 +305,7 @@ function Leaderboard({ defaultDuration = 15, defaultPeriod = 'alltime', layoutMo
{error && <p className="error-message">Error: {error}</p>}
{(hasLoadedOnce ? !showSpinner : !loading) && !error && (
<div className="leaderboard-list">
{leaderboard.length > 0 ? (
leaderboard.map((entry, index) => (
<div
key={`${entry.user_id}-${entry.created_at}`}
className={`leaderboard-item ${user && entry.netid === user.netid ? 'current-user' : ''}`}
>
<span className="leaderboard-rank">{index + 1}</span>
<div
className={`leaderboard-player ${authenticated ? 'clickable' : 'disabled'}`}
onClick={authenticated ? () => handleAvatarClick(entry.avatar_url, entry.netid) : undefined}
title={authenticated ? `View ${entry.netid}'s profile` : 'Log in to view profiles'}
role={authenticated ? 'button' : undefined}
tabIndex={authenticated ? 0 : -1}
onKeyDown={authenticated ? (e => { if (e.key === 'Enter' || e.key === ' ') handleAvatarClick(entry.avatar_url, entry.netid); }) : undefined}
>
<div className="leaderboard-avatar">
<img
src={entry.avatar_url || defaultProfileImage}
alt={`${entry.netid} avatar`}
onError={(e) => { e.target.onerror = null; e.target.src=defaultProfileImage; }}
/>
</div>
<span className="leaderboard-netid">{entry.netid}</span>
</div>
<div className="leaderboard-stats">
<span className="leaderboard-wpm">{parseFloat(entry.adjusted_wpm).toFixed(0)} WPM</span>
<span className="leaderboard-accuracy">{parseFloat(entry.accuracy).toFixed(1)}%</span>
<span className="leaderboard-date">{period === 'daily' ? formatRelativeTime(entry.created_at) : new Date(entry.created_at).toLocaleDateString()}</span>
</div>
</div>
))
) : (
<p className="no-results">No results found for this leaderboard.</p>
)}
{leaderboard.length > 0 ? ( leaderboard.map((entry, index) => ( <div key={`${entry.user_id}-${entry.created_at}`} className={`leaderboard-item ${user && entry.netid === user.netid ? 'current-user' : ''}`}> <span className="leaderboard-rank">{index + 1}</span> <div className="leaderboard-player"> <div className="leaderboard-avatar" onClick={() => handleAvatarClick(entry.avatar_url, entry.netid)} title={`View ${entry.netid}\'s avatar`}> <img src={entry.avatar_url || defaultProfileImage} alt={`${entry.netid} avatar`} onError={(e) => { e.target.onerror = null; e.target.src=defaultProfileImage; }} /> </div> <div className="leaderboard-player-text"> <span className="leaderboard-netid">{entry.netid}</span> {authenticated && (() => { const titles = leaderboardTitlesMap[entry.netid]; const titleToShow = titles?.find(t => t.is_equipped); return titleToShow ? ( <div className="leaderboard-titles"> <span className="leaderboard-title-badge">{titleToShow.name}</span> </div> ) : null; })()} </div> </div> <div className="leaderboard-stats"> <span className="leaderboard-wpm">{parseFloat(entry.adjusted_wpm).toFixed(0)} WPM</span> <span className="leaderboard-accuracy">{parseFloat(entry.accuracy).toFixed(1)}%</span> <span className="leaderboard-date">{period === 'daily' ? formatRelativeTime(entry.created_at) : new Date(entry.created_at).toLocaleDateString()}</span> </div> </div> )) ) : ( <p className="no-results">No results found for this leaderboard.</p> )}
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entire leaderboard rendering logic is condensed into a single line, making it extremely difficult to read and maintain. The JSX should be properly formatted across multiple lines with appropriate indentation.

Suggested change
{leaderboard.length > 0 ? ( leaderboard.map((entry, index) => ( <div key={`${entry.user_id}-${entry.created_at}`} className={`leaderboard-item ${user && entry.netid === user.netid ? 'current-user' : ''}`}> <span className="leaderboard-rank">{index + 1}</span> <div className="leaderboard-player"> <div className="leaderboard-avatar" onClick={() => handleAvatarClick(entry.avatar_url, entry.netid)} title={`View ${entry.netid}\'s avatar`}> <img src={entry.avatar_url || defaultProfileImage} alt={`${entry.netid} avatar`} onError={(e) => { e.target.onerror = null; e.target.src=defaultProfileImage; }} /> </div> <div className="leaderboard-player-text"> <span className="leaderboard-netid">{entry.netid}</span> {authenticated && (() => { const titles = leaderboardTitlesMap[entry.netid]; const titleToShow = titles?.find(t => t.is_equipped); return titleToShow ? ( <div className="leaderboard-titles"> <span className="leaderboard-title-badge">{titleToShow.name}</span> </div> ) : null; })()} </div> </div> <div className="leaderboard-stats"> <span className="leaderboard-wpm">{parseFloat(entry.adjusted_wpm).toFixed(0)} WPM</span> <span className="leaderboard-accuracy">{parseFloat(entry.accuracy).toFixed(1)}%</span> <span className="leaderboard-date">{period === 'daily' ? formatRelativeTime(entry.created_at) : new Date(entry.created_at).toLocaleDateString()}</span> </div> </div> )) ) : ( <p className="no-results">No results found for this leaderboard.</p> )}
{leaderboard.length > 0 ? (
leaderboard.map((entry, index) => (
<div
key={`${entry.user_id}-${entry.created_at}`}
className={`leaderboard-item ${user && entry.netid === user.netid ? 'current-user' : ''}`}
>
<span className="leaderboard-rank">{index + 1}</span>
<div className="leaderboard-player">
<div
className="leaderboard-avatar"
onClick={() => handleAvatarClick(entry.avatar_url, entry.netid)}
title={`View ${entry.netid}'s avatar`}
>
<img
src={entry.avatar_url || defaultProfileImage}
alt={`${entry.netid} avatar`}
onError={(e) => {
e.target.onerror = null;
e.target.src = defaultProfileImage;
}}
/>
</div>
<div className="leaderboard-player-text">
<span className="leaderboard-netid">{entry.netid}</span>
{authenticated && (() => {
const titles = leaderboardTitlesMap[entry.netid];
const titleToShow = titles?.find(t => t.is_equipped);
return titleToShow ? (
<div className="leaderboard-titles">
<span className="leaderboard-title-badge">{titleToShow.name}</span>
</div>
) : null;
})()}
</div>
</div>
<div className="leaderboard-stats">
<span className="leaderboard-wpm">
{parseFloat(entry.adjusted_wpm).toFixed(0)} WPM
</span>
<span className="leaderboard-accuracy">
{parseFloat(entry.accuracy).toFixed(1)}%
</span>
<span className="leaderboard-date">
{period === 'daily'
? formatRelativeTime(entry.created_at)
: new Date(entry.created_at).toLocaleDateString()}
</span>
</div>
</div>
))
) : (
<p className="no-results">No results found for this leaderboard.</p>
)}

Copilot uses AI. Check for mistakes.
</div>
)}
<p className="leaderboard-subtitle">Resets daily at 12:00 AM EST</p>
Expand Down Expand Up @@ -290,22 +357,33 @@ function Leaderboard({ defaultDuration = 15, defaultPeriod = 'alltime', layoutMo
className={`leaderboard-item ${user && entry.netid === user.netid ? 'current-user' : ''}`}
>
<span className="leaderboard-rank">{index + 1}</span>
<div
className={`leaderboard-player ${authenticated ? 'clickable' : 'disabled'}`}
onClick={authenticated ? () => handleAvatarClick(entry.avatar_url, entry.netid) : undefined}
title={authenticated ? `View ${entry.netid}'s profile` : 'Log in to view profiles'}
role={authenticated ? 'button' : undefined}
tabIndex={authenticated ? 0 : -1}
onKeyDown={authenticated ? (e => { if (e.key === 'Enter' || e.key === ' ') handleAvatarClick(entry.avatar_url, entry.netid); }) : undefined}
>
<div className="leaderboard-avatar">
<div className="leaderboard-player">
<div
className={`leaderboard-avatar ${!authenticated ? 'disabled' : ''}`}
onClick={authenticated ? () => handleAvatarClick(entry.avatar_url, entry.netid) : undefined}
title={authenticated ? `View ${entry.netid}\'s profile` : 'Log in to view profiles'}
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected escaped apostrophe from \'s to 's (use proper quote character instead of escape sequence).

Suggested change
title={authenticated ? `View ${entry.netid}\'s profile` : 'Log in to view profiles'}
title={authenticated ? `View ${entry.netid}'s profile` : 'Log in to view profiles'}

Copilot uses AI. Check for mistakes.
>
Comment on lines +363 to +365
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The avatar element is clickable but lacks proper accessibility attributes. Add role=\"button\", tabIndex={authenticated ? 0 : -1}, and keyboard event handlers for Enter/Space keys to ensure keyboard navigation support, similar to the code that was removed from the previous implementation.

Suggested change
onClick={authenticated ? () => handleAvatarClick(entry.avatar_url, entry.netid) : undefined}
title={authenticated ? `View ${entry.netid}\'s profile` : 'Log in to view profiles'}
>
role="button"
tabIndex={authenticated ? 0 : -1}
onClick={authenticated ? () => handleAvatarClick(entry.avatar_url, entry.netid) : undefined}
onKeyDown={authenticated ? (e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleAvatarClick(entry.avatar_url, entry.netid);
}
}) : undefined}
title={authenticated ? `View ${entry.netid}\'s profile` : 'Log in to view profiles'}

Copilot uses AI. Check for mistakes.
<img
src={entry.avatar_url || defaultProfileImage}
alt={`${entry.netid} avatar`}
onError={(e) => { e.target.onerror = null; e.target.src=defaultProfileImage; }}
/>
</div>
<span className="leaderboard-netid">{entry.netid}</span>
<div className="leaderboard-player-text">
<span className="leaderboard-netid">{entry.netid}</span>
{authenticated && (() => {
const titles = leaderboardTitlesMap[entry.netid];
// Only show title if user has one equipped - no fallback to first unlocked title
const titleToShow = titles?.find(t => t.is_equipped);
return titleToShow ? (
<div className="leaderboard-titles">
<span className="leaderboard-title-badge">
{titleToShow.name}
</span>
</div>
) : null;
})()}
</div>
</div>
<div className="leaderboard-stats">
<span className="leaderboard-wpm">{parseFloat(entry.adjusted_wpm).toFixed(0)} WPM</span>
Expand Down
14 changes: 14 additions & 0 deletions client/src/components/ProfileModal.css
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,20 @@
align-items: center;
}

.no-matches {
font-style: italic;
color: var(--text-color-secondary);
font-size: 1.1rem;
padding: 10px 0;
width: 100%;
text-align: center;
margin-left: 0;
min-height: 50px;
display: flex;
align-items: center;
justify-content: center;
}

.title-display.static-title {
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid var(--hover-color);
Expand Down
9 changes: 5 additions & 4 deletions client/src/components/TestConfigurator.css
Original file line number Diff line number Diff line change
Expand Up @@ -259,16 +259,17 @@
max-width: 0;
opacity: 0;
overflow: hidden;
transition: max-width 0.3s ease-out, opacity 0.3s ease-out, margin-left 0.3s ease-out, transform 0.3s ease-out;
transition: max-width 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), margin-left 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
vertical-align: middle;
margin-left: 0;
pointer-events: none;
transform: translateX(-20px);
transform: translateX(-10px);
will-change: max-width, opacity, transform;
}
.subject-filter.visible {
max-width: 200px;
max-width: 250px;
opacity: 1;
margin-left: 5px;
margin-left: 0;
pointer-events: auto;
transform: translateX(0);
}
Expand Down
Loading
Loading