Skip to content

Commit 78f4a2e

Browse files
authored
Merge pull request #2 from TigerAppsOrg/feature/no-error-fix
Free‑typing Timed Mode (No Forced Mistake Fix), Uncapped Public Lobbies, and Docs/Leaderboard UX Refresh
2 parents 4dd7467 + 23e3c42 commit 78f4a2e

12 files changed

Lines changed: 343 additions & 124 deletions

File tree

client/src/components/Leaderboard.css

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,20 @@ h2 {
107107
min-width: 150px; /* Ensure player name area has space */
108108
}
109109

110+
/* Make more of the entry clickable when authenticated */
111+
.leaderboard-player.clickable { cursor: pointer; }
112+
.leaderboard-player.clickable:hover .leaderboard-netid {
113+
color: #F58025;
114+
text-shadow: 0 0 8px rgba(245, 128, 37, 0.5);
115+
transition: color 0.2s ease, text-shadow 0.2s ease;
116+
}
117+
.leaderboard-player.clickable:hover .leaderboard-avatar {
118+
transform: scale(1.08);
119+
border-color: #F58025;
120+
}
121+
.leaderboard-player.disabled { cursor: not-allowed; }
122+
.leaderboard-player.disabled .leaderboard-avatar { cursor: not-allowed; }
123+
110124
.leaderboard-avatar {
111125
width: 38px;
112126
height: 38px;
@@ -136,6 +150,7 @@ h2 {
136150
white-space: nowrap;
137151
overflow: hidden;
138152
text-overflow: ellipsis;
153+
transition: color 0.2s ease, text-shadow 0.2s ease;
139154
}
140155

141156
.leaderboard-stats {
@@ -556,4 +571,4 @@ h2 {
556571
flex-grow: 1;
557572
min-width: 0;
558573
}
559-
}
574+
}

client/src/components/Leaderboard.jsx

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,40 @@ function Leaderboard({ defaultDuration = 15, defaultPeriod = 'alltime', layoutMo
205205
{error && <p className="error-message">Error: {error}</p>}
206206
{(hasLoadedOnce ? !showSpinner : !loading) && !error && (
207207
<div className="leaderboard-list">
208-
{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> )}
208+
{leaderboard.length > 0 ? (
209+
leaderboard.map((entry, index) => (
210+
<div
211+
key={`${entry.user_id}-${entry.created_at}`}
212+
className={`leaderboard-item ${user && entry.netid === user.netid ? 'current-user' : ''}`}
213+
>
214+
<span className="leaderboard-rank">{index + 1}</span>
215+
<div
216+
className={`leaderboard-player ${authenticated ? 'clickable' : 'disabled'}`}
217+
onClick={authenticated ? () => handleAvatarClick(entry.avatar_url, entry.netid) : undefined}
218+
title={authenticated ? `View ${entry.netid}'s profile` : 'Log in to view profiles'}
219+
role={authenticated ? 'button' : undefined}
220+
tabIndex={authenticated ? 0 : -1}
221+
onKeyDown={authenticated ? (e => { if (e.key === 'Enter' || e.key === ' ') handleAvatarClick(entry.avatar_url, entry.netid); }) : undefined}
222+
>
223+
<div className="leaderboard-avatar">
224+
<img
225+
src={entry.avatar_url || defaultProfileImage}
226+
alt={`${entry.netid} avatar`}
227+
onError={(e) => { e.target.onerror = null; e.target.src=defaultProfileImage; }}
228+
/>
229+
</div>
230+
<span className="leaderboard-netid">{entry.netid}</span>
231+
</div>
232+
<div className="leaderboard-stats">
233+
<span className="leaderboard-wpm">{parseFloat(entry.adjusted_wpm).toFixed(0)} WPM</span>
234+
<span className="leaderboard-accuracy">{parseFloat(entry.accuracy).toFixed(1)}%</span>
235+
<span className="leaderboard-date">{period === 'daily' ? formatRelativeTime(entry.created_at) : new Date(entry.created_at).toLocaleDateString()}</span>
236+
</div>
237+
</div>
238+
))
239+
) : (
240+
<p className="no-results">No results found for this leaderboard.</p>
241+
)}
209242
</div>
210243
)}
211244
<p className="leaderboard-subtitle">Resets daily at 12:00 AM EST</p>
@@ -257,12 +290,15 @@ function Leaderboard({ defaultDuration = 15, defaultPeriod = 'alltime', layoutMo
257290
className={`leaderboard-item ${user && entry.netid === user.netid ? 'current-user' : ''}`}
258291
>
259292
<span className="leaderboard-rank">{index + 1}</span>
260-
<div className="leaderboard-player">
261-
<div
262-
className={`leaderboard-avatar ${!authenticated ? 'disabled' : ''}`}
263-
onClick={authenticated ? () => handleAvatarClick(entry.avatar_url, entry.netid) : undefined}
264-
title={authenticated ? `View ${entry.netid}\'s profile` : 'Log in to view profiles'}
265-
>
293+
<div
294+
className={`leaderboard-player ${authenticated ? 'clickable' : 'disabled'}`}
295+
onClick={authenticated ? () => handleAvatarClick(entry.avatar_url, entry.netid) : undefined}
296+
title={authenticated ? `View ${entry.netid}'s profile` : 'Log in to view profiles'}
297+
role={authenticated ? 'button' : undefined}
298+
tabIndex={authenticated ? 0 : -1}
299+
onKeyDown={authenticated ? (e => { if (e.key === 'Enter' || e.key === ' ') handleAvatarClick(entry.avatar_url, entry.netid); }) : undefined}
300+
>
301+
<div className="leaderboard-avatar">
266302
<img
267303
src={entry.avatar_url || defaultProfileImage}
268304
alt={`${entry.netid} avatar`}

client/src/components/TestConfigurator.jsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ function TestConfigurator({
3535
onShowLeaderboard,
3636
isLobby = false,
3737
snippetError = null,
38+
allowTimed = true,
3839
}) {
3940

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

72+
// If timed mode is disallowed (e.g., private lobbies), coerce to snippet mode
73+
React.useEffect(() => {
74+
if (!allowTimed && testMode === 'timed') {
75+
// Update race state first so the server/client agree on mode
76+
setRaceState(prev => ({
77+
...prev,
78+
timedTest: { ...prev.timedTest, enabled: false },
79+
settings: { ...(prev.settings||{}), testMode: 'snippet' }
80+
}));
81+
setTestMode('snippet');
82+
// Load a snippet immediately to reflect the change
83+
setTimeout(() => { loadNewSnippet && loadNewSnippet(); }, 0);
84+
}
85+
}, [allowTimed, testMode, setRaceState, setTestMode, loadNewSnippet]);
86+
7187
// Fetch subjects on mount
7288
React.useEffect(() => {
7389
const fetchSubjects = async () => {
@@ -272,7 +288,7 @@ function TestConfigurator({
272288
{/* Mode Selection Group */}
273289
<div className="config-section mode-selection">
274290
{renderButton('snippet', testMode, setTestMode, 'Snippets', QuoteIcon)}
275-
{renderButton('timed', testMode, setTestMode, 'Timed', ClockIcon)}
291+
{allowTimed && renderButton('timed', testMode, setTestMode, 'Timed', ClockIcon)}
276292
</div>
277293

278294
{/* Separator */}
@@ -372,13 +388,15 @@ function TestConfigurator({
372388
</div>
373389

374390
{/* Timed Mode Duration Wrapper */}
375-
<TutorialAnchor anchorId="timed-options">
376-
<div className={`options-wrapper timed-options ${testMode === 'timed' ? 'visible' : ''}`}>
377-
<div className="config-section duration-selection-inner">
378-
{DURATIONS.map(duration => renderButton(duration, testDuration, setTestDuration, `${duration}s`))}
391+
{allowTimed && (
392+
<TutorialAnchor anchorId="timed-options">
393+
<div className={`options-wrapper timed-options ${testMode === 'timed' ? 'visible' : ''}`}>
394+
<div className="config-section duration-selection-inner">
395+
{DURATIONS.map(duration => renderButton(duration, testDuration, setTestDuration, `${duration}s`))}
396+
</div>
379397
</div>
380-
</div>
381-
</TutorialAnchor>
398+
</TutorialAnchor>
399+
)}
382400

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

@@ -409,6 +427,7 @@ TestConfigurator.propTypes = {
409427
setRaceState: PropTypes.func.isRequired,
410428
loadNewSnippet: PropTypes.func,
411429
onShowLeaderboard: PropTypes.func.isRequired,
430+
allowTimed: PropTypes.bool,
412431
};
413432

414433
export default TestConfigurator;

client/src/components/Typing.css

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,26 @@
226226
.incorrect {
227227
color: var(--incorrect-color)!important;
228228
background-color: var(--incorrect-bg-color)!important;
229-
text-decoration: underline wavy rgba(255, 65, 47, 0.55);
229+
position: relative;
230+
}
231+
232+
/* Visual indicator for incorrectly typed characters - solid underline */
233+
.snippet-display .incorrect::after {
234+
content: '';
235+
position: absolute;
236+
left: 0;
237+
right: 0;
238+
bottom: 0;
239+
height: 2.5px;
240+
background-color: var(--incorrect-color);
241+
opacity: 0.9;
242+
border-radius: 1px;
243+
pointer-events: none;
244+
}
245+
246+
/* Caret cursor: remove red background, keep solid underline */
247+
:root[data-cursor='caret'] .snippet-display .incorrect {
248+
background-color: transparent !important;
230249
}
231250

232251
.shake-animation {
@@ -415,9 +434,9 @@
415434
pointer-events: none;
416435
z-index: 0; /* behind the text */
417436
transition:
418-
transform var(--cursor-glide-duration, 90ms) cubic-bezier(0.24, 0.92, 0.35, 1),
419-
width var(--cursor-glide-duration, 90ms) cubic-bezier(0.24, 0.92, 0.35, 1),
420-
height var(--cursor-glide-duration, 90ms) cubic-bezier(0.24, 0.92, 0.35, 1);
437+
transform var(--cursor-glide-duration, 90ms) ease-in-out,
438+
width var(--cursor-glide-duration, 90ms) ease-in-out,
439+
height var(--cursor-glide-duration, 90ms) ease-in-out;
421440
opacity: calc(var(--glide-cursor-enabled, 0)); /* 0 or 1 set by Settings */
422441
border-radius: 2px;
423442
backface-visibility: hidden;
@@ -430,8 +449,7 @@
430449

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

0 commit comments

Comments
 (0)