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
17 changes: 16 additions & 1 deletion client/src/components/Leaderboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,20 @@ h2 {
min-width: 150px; /* Ensure player name area has space */
}

/* Make more of the entry clickable when authenticated */
.leaderboard-player.clickable { cursor: pointer; }
.leaderboard-player.clickable:hover .leaderboard-netid {
color: #F58025;
text-shadow: 0 0 8px rgba(245, 128, 37, 0.5);
transition: color 0.2s ease, text-shadow 0.2s ease;
}
.leaderboard-player.clickable:hover .leaderboard-avatar {
transform: scale(1.08);
border-color: #F58025;
}
.leaderboard-player.disabled { cursor: not-allowed; }
.leaderboard-player.disabled .leaderboard-avatar { cursor: not-allowed; }

.leaderboard-avatar {
width: 38px;
height: 38px;
Expand Down Expand Up @@ -136,6 +150,7 @@ h2 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: color 0.2s ease, text-shadow 0.2s ease;
}

.leaderboard-stats {
Expand Down Expand Up @@ -556,4 +571,4 @@ h2 {
flex-grow: 1;
min-width: 0;
}
}
}
50 changes: 43 additions & 7 deletions client/src/components/Leaderboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,40 @@ 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"> <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> <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 ${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}
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

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

When handling keyboard events for accessibility, the Space key press should call e.preventDefault() to prevent the page from scrolling. Add e.preventDefault(); before calling handleAvatarClick when Space is pressed.

Suggested change
onKeyDown={authenticated ? (e => { if (e.key === 'Enter' || e.key === ' ') handleAvatarClick(entry.avatar_url, entry.netid); }) : undefined}
onKeyDown={authenticated ? (e => {
if (e.key === 'Enter') {
handleAvatarClick(entry.avatar_url, entry.netid);
} else if (e.key === ' ') {
e.preventDefault();
handleAvatarClick(entry.avatar_url, entry.netid);
}
}) : undefined}

Copilot uses AI. Check for mistakes.
>
<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>
)}
</div>
)}
<p className="leaderboard-subtitle">Resets daily at 12:00 AM EST</p>
Expand Down Expand Up @@ -257,12 +290,15 @@ 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">
<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'}
>
<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}
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

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

The keyboard event handler for Space key will trigger both the click handler and cause the page to scroll. Should call e.preventDefault() when handling the Space key to prevent default scroll behavior.

Suggested change
onKeyDown={authenticated ? (e => { if (e.key === 'Enter' || e.key === ' ') handleAvatarClick(entry.avatar_url, entry.netid); }) : undefined}
onKeyDown={authenticated ? (e => { if (e.key === 'Enter' || e.key === ' ') { if (e.key === ' ') e.preventDefault(); handleAvatarClick(entry.avatar_url, entry.netid); } }) : undefined}

Copilot uses AI. Check for mistakes.
>
<div className="leaderboard-avatar">
<img
src={entry.avatar_url || defaultProfileImage}
alt={`${entry.netid} avatar`}
Expand Down
33 changes: 26 additions & 7 deletions client/src/components/TestConfigurator.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function TestConfigurator({
onShowLeaderboard,
isLobby = false,
snippetError = null,
allowTimed = true,
}) {

const [subjects, setSubjects] = React.useState(['all']);
Expand Down Expand Up @@ -68,6 +69,21 @@ function TestConfigurator({
fetchDifficulties();
}, [snippetCategory, snippetSubject]);

// If timed mode is disallowed (e.g., private lobbies), coerce to snippet mode
React.useEffect(() => {
if (!allowTimed && testMode === 'timed') {
Comment on lines +73 to +74
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

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

The useEffect hook (lines 73-85) is missing testMode from its dependency array on line 85. This could cause the effect to run unnecessarily or not run when expected. The dependency array should include all values used inside the effect.

Copilot uses AI. Check for mistakes.
// Update race state first so the server/client agree on mode
setRaceState(prev => ({
...prev,
timedTest: { ...prev.timedTest, enabled: false },
settings: { ...(prev.settings||{}), testMode: 'snippet' }
}));
setTestMode('snippet');
// Load a snippet immediately to reflect the change
setTimeout(() => { loadNewSnippet && loadNewSnippet(); }, 0);
}
}, [allowTimed, testMode, setRaceState, setTestMode, loadNewSnippet]);
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

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

This useEffect can trigger infinite re-renders if loadNewSnippet is not memoized. The effect depends on loadNewSnippet, but when it calls loadNewSnippet inside the setTimeout, state changes could recreate the function reference, causing the effect to run again. Consider wrapping loadNewSnippet with useCallback in the parent component or removing it from the dependency array if it's stable.

Suggested change
}, [allowTimed, testMode, setRaceState, setTestMode, loadNewSnippet]);
}, [allowTimed, testMode, setRaceState, setTestMode]);

Copilot uses AI. Check for mistakes.

// Fetch subjects on mount
React.useEffect(() => {
const fetchSubjects = async () => {
Expand Down Expand Up @@ -272,7 +288,7 @@ function TestConfigurator({
{/* Mode Selection Group */}
<div className="config-section mode-selection">
{renderButton('snippet', testMode, setTestMode, 'Snippets', QuoteIcon)}
{renderButton('timed', testMode, setTestMode, 'Timed', ClockIcon)}
{allowTimed && renderButton('timed', testMode, setTestMode, 'Timed', ClockIcon)}
</div>

{/* Separator */}
Expand Down Expand Up @@ -372,13 +388,15 @@ function TestConfigurator({
</div>

{/* Timed Mode Duration Wrapper */}
<TutorialAnchor anchorId="timed-options">
<div className={`options-wrapper timed-options ${testMode === 'timed' ? 'visible' : ''}`}>
<div className="config-section duration-selection-inner">
{DURATIONS.map(duration => renderButton(duration, testDuration, setTestDuration, `${duration}s`))}
{allowTimed && (
<TutorialAnchor anchorId="timed-options">
<div className={`options-wrapper timed-options ${testMode === 'timed' ? 'visible' : ''}`}>
<div className="config-section duration-selection-inner">
{DURATIONS.map(duration => renderButton(duration, testDuration, setTestDuration, `${duration}s`))}
</div>
</div>
</div>
</TutorialAnchor>
</TutorialAnchor>
)}

</div> {/* End Conditional Options Container */}

Expand Down Expand Up @@ -409,6 +427,7 @@ TestConfigurator.propTypes = {
setRaceState: PropTypes.func.isRequired,
loadNewSnippet: PropTypes.func,
onShowLeaderboard: PropTypes.func.isRequired,
allowTimed: PropTypes.bool,
};

export default TestConfigurator;
30 changes: 24 additions & 6 deletions client/src/components/Typing.css
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,26 @@
.incorrect {
color: var(--incorrect-color)!important;
background-color: var(--incorrect-bg-color)!important;
text-decoration: underline wavy rgba(255, 65, 47, 0.55);
position: relative;
}

/* Visual indicator for incorrectly typed characters - solid underline */
.snippet-display .incorrect::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2.5px;
background-color: var(--incorrect-color);
opacity: 0.9;
border-radius: 1px;
pointer-events: none;
}

/* Caret cursor: remove red background, keep solid underline */
:root[data-cursor='caret'] .snippet-display .incorrect {
background-color: transparent !important;
}

.shake-animation {
Expand Down Expand Up @@ -415,9 +434,9 @@
pointer-events: none;
z-index: 0; /* behind the text */
transition:
transform var(--cursor-glide-duration, 90ms) cubic-bezier(0.24, 0.92, 0.35, 1),
width var(--cursor-glide-duration, 90ms) cubic-bezier(0.24, 0.92, 0.35, 1),
height var(--cursor-glide-duration, 90ms) cubic-bezier(0.24, 0.92, 0.35, 1);
transform var(--cursor-glide-duration, 90ms) ease-in-out,
width var(--cursor-glide-duration, 90ms) ease-in-out,
height var(--cursor-glide-duration, 90ms) ease-in-out;
opacity: calc(var(--glide-cursor-enabled, 0)); /* 0 or 1 set by Settings */
border-radius: 2px;
backface-visibility: hidden;
Expand All @@ -430,8 +449,7 @@

.cursor-overlay.caret {
background: transparent;
border-left: var(--caret-width, 3px) solid var(--caret-color);
box-shadow: 0 0 0.001px var(--caret-color); /* sub-pixel hinting */
border-left: var(--caret-width, 2px) solid var(--caret-color);
z-index: 2; /* above text so thin caret remains visible */
animation: caretBlink 1.2s ease-in-out infinite;
}
Expand Down
Loading
Loading