From a726d9f5fb56e1fd7983c5ac806d408ad78daa86 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Thu, 11 Sep 2025 21:53:00 +0200 Subject: Add multiplayer bots, typing languages, and rework the UI layout Multiplayer - Quick match: POST /api/multiplayer/quickmatch returns whichever room is still filling, or opens one. Players never see a room code; joining by code stays for racing specific people. - Bots fill quick-match rooms after a short wait so a new game is never an empty lobby. They only ever join quick-match rooms, never a room opened by code. One or two per room, drawn from separate ~40 and ~80 WPM tiers so two bots are never near each other's pace, and they stall to correct mistakes rather than typing a clean straight line. - Live player count via GET /api/multiplayer/online, shown on the Multiplayer control and under the main menu's Multiplayer button. - Per-racer colours: you are the theme accent, opponents take distinct hues that stay the same from lobby to race. - The countdown no longer holds the room lock for its full three seconds, which is what reset clients mid-countdown. Typing languages - 16 languages for the generated-word modes, each with its own high-frequency vocabulary rather than a translation of the English list. - Picker in the top-right rail; non-English uses its own list at every difficulty tier instead of falling back to English words. Fix UTF-8 accuracy in the game core - update_game_state mixed byte and character counts: total_characters_typed accumulated byte-length deltas while total_correct_characters compared a char index against that byte count. Equal on ASCII, so it went unnoticed; a correctly typed Spanish passage scored 6%. The old byte slicing would also have panicked if an index landed inside a multi-byte character. Rewritten char-based, with regression tests. Programming mode - Replaced prose about programming with real code: 26 syntax-highlighted snippets across JavaScript, Python, Rust, C/Go/Java and shell. Single-line by necessity, since the typing input is a single-line field. Layout and readability - One icon rail arrangement on every screen: Settings/Store under the wordmark, Language/Theme/Friends/Account top-right, Stats/Leaderboard/ Multiplayer bottom-right. - Main menu: mode picker moved out of the Single Player button, which it was notching a divider through and pushing the label off-centre. - Escape returns to the menu, closing any open popover first, and confirms before abandoning a live race. - Split --text-color and --sub-color per theme; they shared one value that measured 3.65:1 against the background, below the 4.5:1 body-text floor. - Semantic colours used in exactly one place each: gold for a personal best, amber for the race countdown and the mobile-result badge. - Passage now sits in the same place on the typing and end screens, and its column is a whole number of characters wide so wrapping cannot leave a permanent gap on the right. - End screen: keystrokes and a correct/wrong/extra/missed split, attribution carried over from the typing screen, and a graph with a separate error axis, axis titles including seconds, and smoothed lines. --- web/src/screens/accountScreen.js | 121 ++++++++ web/src/screens/endScreen.js | 226 ++++++++++++++ web/src/screens/friendsScreen.js | 149 +++++++++ web/src/screens/icons.js | 109 +++++++ web/src/screens/leaderboardScreen.js | 120 ++++++++ web/src/screens/lyricsScreen.js | 127 ++++++++ web/src/screens/mainMenu.js | 531 +++++++++++++++++++++++++++++++++ web/src/screens/multiplayerScreen.js | 265 ++++++++++++++++ web/src/screens/passiveMode.js | 159 ++++++++++ web/src/screens/placeholderScreen.js | 31 ++ web/src/screens/publicProfileScreen.js | 92 ++++++ web/src/screens/statsScreen.js | 85 ++++++ web/src/screens/storeScreen.js | 145 +++++++++ web/src/screens/typingGame.js | 309 +++++++++++++++++++ 14 files changed, 2469 insertions(+) create mode 100644 web/src/screens/accountScreen.js create mode 100644 web/src/screens/endScreen.js create mode 100644 web/src/screens/friendsScreen.js create mode 100644 web/src/screens/icons.js create mode 100644 web/src/screens/leaderboardScreen.js create mode 100644 web/src/screens/lyricsScreen.js create mode 100644 web/src/screens/mainMenu.js create mode 100644 web/src/screens/multiplayerScreen.js create mode 100644 web/src/screens/passiveMode.js create mode 100644 web/src/screens/placeholderScreen.js create mode 100644 web/src/screens/publicProfileScreen.js create mode 100644 web/src/screens/statsScreen.js create mode 100644 web/src/screens/storeScreen.js create mode 100644 web/src/screens/typingGame.js (limited to 'web/src/screens') diff --git a/web/src/screens/accountScreen.js b/web/src/screens/accountScreen.js new file mode 100644 index 0000000..30161d9 --- /dev/null +++ b/web/src/screens/accountScreen.js @@ -0,0 +1,121 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { getUser, onAuthChange, register, login, logout, ApiError } from '../auth.js'; +import { api, apiUrl } from '../api.js'; + +// onShowAccount is accepted but intentionally not supplied by app.js for this +// screen: the top rail's identity control is already pointing here, so it has +// nothing to navigate to. Declared so the shared rail call below resolves. +export function renderAccountScreen(root, { onBack, onShowAccount, onShowStats, onShowPlaceholder, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + let mode = 'login'; // 'login' | 'register' + let stopped = false; + // null = checking, true/false once the now-playing probe resolves -- + // there's no dedicated "am I connected" endpoint, but now-playing + // itself 404s specifically when there's no stored Spotify connection, + // so it doubles as the status check. + let spotifyConnected = null; + let spotifyCheckedForUserId = null; + + function checkSpotifyStatus(userId) { + spotifyCheckedForUserId = userId; + spotifyConnected = null; + api.get('/api/spotify/now-playing') + .then(() => { spotifyConnected = true; rerender(); }) + .catch(err => { spotifyConnected = !(err instanceof ApiError && err.status === 404); rerender(); }); + } + + function spotifyStatusMarkup() { + if (spotifyConnected === null) return `
Checking...
`; + if (spotifyConnected) return `
Connected
`; + return `Connect Spotify`; + } + + function formMarkup(user) { + if (user) { + return ` +
Signed in as ${escapeHtml(user.username)}
+ +
Spotify
+ ${spotifyStatusMarkup()} + `; + } + return ` +
+ + +
+
+ + + +
+
+ `; + } + + function render() { + const user = getUser(); + root.innerHTML = ` +
+ +

Account

+
${formMarkup(user)}
+ +
+ `; + + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const logoutBtn = root.querySelector('[data-action="logout"]'); + if (logoutBtn) logoutBtn.addEventListener('click', async () => { await logout(); }); + + const tabLogin = root.querySelector('[data-action="tab-login"]'); + const tabRegister = root.querySelector('[data-action="tab-register"]'); + if (tabLogin) tabLogin.addEventListener('click', () => { mode = 'login'; rerender(); }); + if (tabRegister) tabRegister.addEventListener('click', () => { mode = 'register'; rerender(); }); + + const form = root.querySelector('.account-form'); + if (form) { + form.addEventListener('submit', async e => { + e.preventDefault(); + const errorEl = form.querySelector('.account-error'); + errorEl.textContent = ''; + const username = form.username.value.trim(); + const password = form.password.value; + try { + if (mode === 'login') await login(username, password); + else await register(username, password); + } catch (err) { + errorEl.textContent = err instanceof ApiError ? err.message : 'Something went wrong - try again.'; + } + }); + } + + if (user && spotifyCheckedForUserId !== user.id) checkSpotifyStatus(user.id); + if (!user) spotifyCheckedForUserId = null; + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + return () => { + cleanupTheme(); + cleanupRail(); + }; + } + + let cleanup = render(); + function rerender() { + if (stopped) return; + cleanup(); + cleanup = render(); + } + const unsubscribeAuth = onAuthChange(rerender); + + return () => { + stopped = true; + unsubscribeAuth(); + cleanup(); + }; +} diff --git a/web/src/screens/endScreen.js b/web/src/screens/endScreen.js new file mode 100644 index 0000000..cd2a41d --- /dev/null +++ b/web/src/screens/endScreen.js @@ -0,0 +1,226 @@ +import { buildGraphPoints, buildErrorPoints, drawChart, hitTestError, hitTestLine, calculateConsistency } from '../chart.js'; +import { escapeHtml } from '../util.js'; +import { attachTooltips } from '../tooltip.js'; +import { recordResult } from '../pb.js'; +import { recordTest } from '../profileStats.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { renderTopRail } from '../topRail.js'; +import { submitTestResult } from '../auth.js'; +import { detectDeviceType } from '../deviceDetect.js'; + +export function renderEndScreen(root, { stats, text, attribution, userInput, charTimings, keypressHistory, modeKey, onPlayAgain, onMainMenu, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + // Monkeytype's four-way split. "Extra" is anything typed past the end of + // the passage, "missed" is passage left untyped - neither is visible in + // a plain correct/incorrect pair, and the two mean very different things + // about a run that ended early. + const typedLen = (userInput || '').length; + const extraChars = Math.max(0, typedLen - (text || '').length); + const missedChars = Math.max(0, (text || '').length - typedLen); + const graphPoints = buildGraphPoints(charTimings, stats, keypressHistory); + const consistency = calculateConsistency(graphPoints); + const { isNewBest, previous } = recordResult(modeKey, stats.wpm); + recordTest({ wpm: stats.wpm, accuracy: stats.accuracy, time: stats.time }); + // Same exclusion as recordResult above: custom text/Zen have no stable, + // repeatable challenge, so there's no meaningful leaderboard entry for + // them either. + if (modeKey) { + // Inter-keystroke gaps (not raw timestamps) - the server's + // anti-cheat check only cares about the *variance* between + // consecutive keystrokes, not when the test started. + const source = keypressHistory && keypressHistory.length > 1 ? keypressHistory : null; + const keystrokeIntervalsMs = source + ? source.slice(1).map((k, i) => (k.time - source[i].time) * 1000).filter(ms => ms >= 0) + : undefined; + submitTestResult({ + mode_key: modeKey, + wpm: stats.wpm, + raw_wpm: stats.rawWpm, + accuracy: stats.accuracy, + time_seconds: stats.time, + device_type: detectDeviceType(), + keystroke_intervals_ms: keystrokeIntervalsMs, + }); + } + // Don't announce "new best" on a mode's very first-ever run - there's + // nothing to have beaten yet, so it just silently becomes the baseline. + const pbLine = !modeKey || previous == null ? '' : isNewBest + ? '
NEW BEST
' + : `
PB ${Math.round(previous)}
`; + + root.innerHTML = ` +
+ +
+ ${attribution ? `
— ${escapeHtml(attribution)}
` : ''} +
+
+
WPM
${Math.round(stats.wpm)}
+ ${pbLine} +
+
+
+
ACC
${Math.round(stats.accuracy)}%
+
+
+
+
RAW
${Math.round(stats.rawWpm)}
+
CONSISTENCY
${consistency}%
+
KEYSTROKES
${stats.keystrokes}
+
+
CHARACTERS
${stats.correctChars}/${stats.incorrectChars}/${extraChars}/${missedChars}
+
+
TIME
${stats.time.toFixed(1)}s
+
+
+ +
+
+ `; + + const textDisplay = root.querySelector('.text-display'); + const inputChars = userInput ? userInput.split('') : []; + const wordMatches = text.match(/[^\s]+\s*/g) || []; + let charIndex = 0; + const html = []; + for (const word of wordMatches) { + html.push(''); + for (const char of word) { + const inputChar = inputChars[charIndex]; + let className = 'neutral'; + let displayChar = char; + if (charIndex < inputChars.length) { + className = inputChar === char ? 'correct' : 'incorrect'; + if (className === 'incorrect') displayChar = inputChar; + } + html.push(`${escapeHtml(displayChar)}`); + charIndex++; + } + html.push(''); + } + textDisplay.innerHTML = html.join(''); + + attachTooltips(root.querySelector('.end-screen-stat-row')); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onMainMenu)); + root.querySelector('[data-action="again"]').addEventListener('click', onPlayAgain); + + const canvas = root.querySelector('canvas'); + const errorPoints = buildErrorPoints(graphPoints, keypressHistory, charTimings); + const xMax = Math.max(1, Math.ceil(stats.time)); + const redraw = () => drawChart(canvas, { graphPoints, errorPoints, xMax }); + redraw(); + window.addEventListener('resize', redraw); + + // A long passage wraps to more lines than a short one, growing + // .end-screen-text's height by an amount no fixed CSS margin/graph-height + // budget can account for - a static tuning only ever fits whichever + // passage happened to be on screen when it was measured. This claws the + // room back in stages instead, each one only kicking in if the last + // wasn't enough, so the page itself never needs to scroll to reach + // Play Again: shrink the graph down to a floor, then trim the button's + // top margin, and only as a last resort (a passage long enough that even + // both of those can't absorb it - realistically only reachable by + // pasting the full text-mode word buffer instead of actually typing it + // in the time given) let the passage preview itself scroll internally, + // which keeps every control below it reachable without the page + // scrolling. Runs once after layout settles. + requestAnimationFrame(() => { + const doc = document.documentElement; + // Re-measured after every stage rather than tracked as a running + // subtraction - margin-collapse and sub-pixel rounding meant an + // assumed "reduced height X means Y px less overflow" estimate + // drifted from the DOM's actual scrollHeight, consistently + // undershooting by a fixed amount however much was cut. + const overflowNow = () => doc.scrollHeight - doc.clientHeight; + if (overflowNow() <= 0) return; + + const graphBox = root.querySelector('.graph-container'); + const MIN_GRAPH_HEIGHT = 120; + const graphHeight = graphBox.getBoundingClientRect().height; + if (graphHeight > MIN_GRAPH_HEIGHT) { + const reduceBy = Math.min(overflowNow(), graphHeight - MIN_GRAPH_HEIGHT); + graphBox.style.height = `${graphHeight - reduceBy}px`; + redraw(); + } + if (overflowNow() <= 0) return; + + const buttons = root.querySelector('.end-screen-buttons'); + const buttonsMargin = parseFloat(getComputedStyle(buttons).marginTop); + if (buttonsMargin > 8) { + const reduceBy = Math.min(overflowNow(), buttonsMargin - 8); + buttons.style.marginTop = `${buttonsMargin - reduceBy}px`; + } + if (overflowNow() <= 0) return; + + const textBox = root.querySelector('.end-screen-text'); + const textHeight = textBox.getBoundingClientRect().height; + textBox.style.maxHeight = `${Math.max(80, textHeight - overflowNow() - 4)}px`; + textBox.style.overflowY = 'auto'; + }); + + const tooltip = document.createElement('div'); + tooltip.className = 'chart-tooltip'; + root.querySelector('.graph-container').appendChild(tooltip); + + const handleMove = e => { + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const errorHit = hitTestError(canvas, errorPoints, graphPoints, xMax, mx, my); + if (errorHit) { + canvas.style.cursor = 'pointer'; + const word = errorHit.index != null ? wordAt(text, errorHit.index) : ''; + const n = errorHit.count || 1; + tooltip.textContent = n > 1 + ? `${n} errors${word ? ` · first at "${word}"` : ''}` + : (word ? `Error: "${word}"` : 'Error'); + tooltip.style.left = `${mx}px`; + tooltip.style.top = `${my}px`; + tooltip.style.display = 'block'; + return; + } + const linePoint = hitTestLine(canvas, graphPoints, xMax, mx); + if (linePoint) { + canvas.style.cursor = 'crosshair'; + tooltip.textContent = `${linePoint.time}s · wpm ${Math.round(linePoint.wpm)} · raw ${Math.round(linePoint.raw)}`; + tooltip.style.left = `${mx}px`; + tooltip.style.top = `${my}px`; + tooltip.style.display = 'block'; + } else { + canvas.style.cursor = 'default'; + tooltip.style.display = 'none'; + } + }; + const handleLeave = () => { tooltip.style.display = 'none'; }; + canvas.addEventListener('mousemove', handleMove); + canvas.addEventListener('mouseleave', handleLeave); + + // Quick restart: Tab on the results screen is the keyboard equivalent + // of clicking Play Again. + const handleKeydown = e => { + if (e.key === 'Tab') { + e.preventDefault(); + onPlayAgain(); + } + }; + document.addEventListener('keydown', handleKeydown); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + + return () => { + cleanupTheme(); + window.removeEventListener('resize', redraw); + canvas.removeEventListener('mousemove', handleMove); + canvas.removeEventListener('mouseleave', handleLeave); + document.removeEventListener('keydown', handleKeydown); + cleanupRail(); + }; +} + +function wordAt(text, index) { + let start = index; + let end = index; + while (start > 0 && text[start - 1] !== ' ') start--; + while (end < text.length && text[end] !== ' ') end++; + return text.slice(start, end).trim(); +} diff --git a/web/src/screens/friendsScreen.js b/web/src/screens/friendsScreen.js new file mode 100644 index 0000000..89317ad --- /dev/null +++ b/web/src/screens/friendsScreen.js @@ -0,0 +1,149 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { api, ApiError } from '../api.js'; +import { getUser, onAuthChange } from '../auth.js'; + +// onShowFriends is accepted but not supplied by app.js for this screen - the +// top rail's Friends control already points here, so it has nowhere to go. +// Declared so the shared rail call below resolves. +export function renderFriendsScreen(root, { onBack, onShowFriends, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowMultiplayer, onShowStore }) { + let data = { friends: [], incoming_requests: [], outgoing_requests: [] }; + let loadError = null; + + async function load() { + if (!getUser()) return; + try { + data = await api.get('/api/friends'); + loadError = null; + } catch (err) { + loadError = err instanceof ApiError ? err.message : 'network error'; + } + } + + function signedOutMarkup() { + return `
Sign in to add friends and compare stats.
+ `; + } + + function signedInMarkup() { + return ` +
+ + +
+
+ + ${data.incoming_requests.length > 0 ? ` +

Requests

+
+ ${data.incoming_requests.map(r => ` +
+
${escapeHtml(r.username)}
+ + +
+ `).join('')} +
` : ''} + + ${data.outgoing_requests.length > 0 ? ` +

Sent

+
+ ${data.outgoing_requests.map(r => ` +
+
${escapeHtml(r.username)}
+
Pending
+ +
+ `).join('')} +
` : ''} + +

Friends

+ ${data.friends.length > 0 ? ` +
+ ${data.friends.map(f => ` +
+
${escapeHtml(f.username)}
+ +
+ `).join('')} +
` : `
No friends yet - add one by username above.
`} + + ${loadError ? `
${escapeHtml(loadError)}
` : ''} + `; + } + + function render() { + const user = getUser(); + root.innerHTML = ` +
+ +

Friends

+ ${user ? signedInMarkup() : signedOutMarkup()} + +
+ `; + + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const goAccount = root.querySelector('[data-action="go-account"]'); + if (goAccount) goAccount.addEventListener('click', onShowAccount); + + const addForm = root.querySelector('.friends-add-form'); + if (addForm) { + addForm.addEventListener('submit', async e => { + e.preventDefault(); + const errorEl = addForm.querySelector('.friends-add-error'); + errorEl.textContent = ''; + const username = addForm.username.value.trim(); + try { + await api.post('/api/friends/request', { username }); + addForm.username.value = ''; + await load(); + rerender(); + } catch (err) { + errorEl.textContent = err instanceof ApiError ? err.message : 'Something went wrong - try again.'; + } + }); + } + + root.querySelectorAll('[data-action="accept"]').forEach(btn => { + btn.addEventListener('click', async () => { + await api.post(`/api/friends/${btn.dataset.id}/accept`).catch(() => {}); + await load(); + rerender(); + }); + }); + root.querySelectorAll('[data-action="decline"], [data-action="cancel"], [data-action="unfriend"]').forEach(btn => { + btn.addEventListener('click', async () => { + await api.delete(`/api/friends/${btn.dataset.id}`).catch(() => {}); + await load(); + rerender(); + }); + }); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowMultiplayer, onShowStore }); + return () => { + cleanupTheme(); + cleanupRail(); + }; + } + + let cleanup; + function rerender() { + if (cleanup) cleanup(); + cleanup = render(); + } + + rerender(); + load().then(rerender); + const unsubscribeAuth = onAuthChange(() => { load().then(rerender); }); + + return () => { + unsubscribeAuth(); + if (cleanup) cleanup(); + }; +} diff --git a/web/src/screens/icons.js b/web/src/screens/icons.js new file mode 100644 index 0000000..c088c0d --- /dev/null +++ b/web/src/screens/icons.js @@ -0,0 +1,109 @@ +import { isDarkTheme } from '../theme.js'; + +// Angular, straight-line glyphs to match the site's sharp-cornered, +// terminal-styled UI - rounded/organic icons (circles, bezier crescents) +// read as off-brand next to bordered rectangular buttons and monospace text. +const SUN = ` + + +`; + +// A true cut-out crescent (fill-rule evenodd punches a hexagonal hole out of +// a larger hexagon) rather than a color-matched overlay, so it renders +// correctly against every theme's background without special-casing. +const MOON = ` + +`; + +// Shows the icon for the theme currently active (moon while in any +// dark-family theme, sun while in Light) rather than just checking for the +// literal string "dark" - that missed every gallery theme (Dracula, Nord, +// etc.), which are all dark-background but not literally named "dark". +export function themeIcon(theme) { + return isDarkTheme(theme) ? MOON : SUN; +} + +export const STATS_ICON = ` + + + +`; + +// Three sliders (an equalizer/preferences glyph) - the hex-nut-with-teeth +// gear this replaced still read too much like the hexagon family shared by +// Moon/Account/Friends at a glance despite the teeth. This is unambiguous as +// "settings" on its own and shares no shape language with anything else in +// the icon set. +export const SETTINGS_ICON = ` + + + + + + +`; + +// Two overlapping triangle "figures" - same flat-geometric language as the +// other corner icons, reads as "two players" without a literal person glyph. +export const MULTIPLAYER_ICON = ` + + +`; + +export const LEADERBOARD_ICON = ` + + + +`; + +// Two overlapping copies of ACCOUNT_ICON's head-and-shoulders figure - two +// small bare hexagons (an earlier draft) blurred into two indistinct dots at +// this icon's actual render size; a head+shoulders silhouette keeps reading +// as "people" even that small. +export const FRIENDS_ICON = ` + + + + +`; + +export const ACCOUNT_ICON = ` + + +`; + +// A price tag - angular throughout (straight edges, a punched square hole +// instead of a round one), matching the rest of the set. +export const STORE_ICON = ` + +`; + +// A stroked chevron - an SVG path centers and scales identically everywhere, +// unlike a unicode "▾" glyph, whose vertical metrics vary by font and don't +// sit centered in a fixed-size box the way this does. +export const CHEVRON_DOWN_ICON = ` + +`; + +// Cosmetic flair badges (see cosmetics.rs) - angular throughout like every +// other icon here, a 4-point sparkle instead of a rounded star so it +// doesn't stand out as the one curved glyph in the set. +export const FLAIR_ICONS = { + star: ``, + bolt: ``, + skull: ``, +}; + +// Typing-language picker. The convention for this control is a glyph pair -- +// one letterform from a non-Latin script beside a Latin "A" - which is what +// 10FastFingers, MonkeyType and browser translate buttons all use. Drawn as +// straight-edged strokes rather than set in a font, so it matches the rest of +// this angular set and does not depend on a CJK face being installed. +export const LANGUAGE_ICON = ` + + + + + + +`; diff --git a/web/src/screens/leaderboardScreen.js b/web/src/screens/leaderboardScreen.js new file mode 100644 index 0000000..2777b1c --- /dev/null +++ b/web/src/screens/leaderboardScreen.js @@ -0,0 +1,120 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { api, ApiError } from '../api.js'; +import { FLAIR_ICONS } from './icons.js'; + +const WORD_COUNTS = [10, 25, 50, 100]; +const TIME_DURATIONS = [15, 30, 60, 120]; + +// Scoped to Words/Time only, not the open-ended quote categories - those +// grow with the text pack and don't make a stable, comparable leaderboard +// mode the way a fixed word count or time duration does. +const MODES = [ + ...TIME_DURATIONS.map(d => ({ key: `time-${d}`, label: `Time ${d}s` })), + ...WORD_COUNTS.map(w => ({ key: `words-${w}`, label: `Words ${w}` })), +]; + +function formatDate(iso) { + const t = Date.parse(iso); + return Number.isNaN(t) ? '' : new Date(t).toLocaleDateString(); +} + +export function renderLeaderboardScreen(root, { onBack, onShowPublicProfile, onShowStats, onShowPlaceholder, onShowAccount, onShowFriends, onShowMultiplayer, onShowStore }) { + let selectedMode = MODES[1].key; // time-30, matches the app's own default mode + let desktopOnly = false; + + function rowsMarkup(state) { + if (state.status === 'loading') return `
Loading...
`; + if (state.status === 'error') return `
Could not load the leaderboard - ${escapeHtml(state.message)}
`; + if (state.entries.length === 0) return `
No results yet for this mode - be the first.
`; + return ` +
+ ${state.entries.map((e, i) => ` +
+
#${i + 1}
+ + ${e.device_type === 'mobile' ? `mobile` : ''} +
${Math.round(e.wpm)} wpm
+
${Math.round(e.accuracy)}%
+
${formatDate(e.date)}
+
+ `).join('')} +
+ `; + } + + root.innerHTML = ` +
+ +

Leaderboard

+
+ ${MODES.map(m => ``).join('')} +
+ +
+ +
+ `; + + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const resultsEl = root.querySelector('.leaderboard-results'); + let requestToken = 0; + async function load() { + const token = ++requestToken; + resultsEl.innerHTML = rowsMarkup({ status: 'loading' }); + try { + const params = new URLSearchParams({ mode: selectedMode }); + if (desktopOnly) params.set('device', 'desktop'); + const entries = await api.get(`/api/leaderboard?${params.toString()}`); + if (token !== requestToken) return; // a newer mode pick already superseded this request + resultsEl.innerHTML = rowsMarkup({ status: 'ok', entries }); + // Scoped to resultsEl, not the whole root - attachTooltips + // already ran once for the rest of the screen at render time, + // and re-running it there would double-bind those elements' + // listeners on every reload. + attachTooltips(resultsEl); + } catch (err) { + if (token !== requestToken) return; + const message = err instanceof ApiError ? err.message : 'network error'; + resultsEl.innerHTML = rowsMarkup({ status: 'error', message }); + } + } + + root.querySelectorAll('.leaderboard-modes [data-mode]').forEach(btn => { + btn.addEventListener('click', () => { + selectedMode = btn.dataset.mode; + root.querySelectorAll('.leaderboard-modes [data-mode]').forEach(b => b.classList.toggle('ghost', b !== btn)); + load(); + }); + }); + + const deviceFilterBtn = root.querySelector('[data-action="toggle-device-filter"]'); + deviceFilterBtn.addEventListener('click', () => { + desktopOnly = !desktopOnly; + deviceFilterBtn.textContent = `Devices: ${desktopOnly ? 'Desktop only' : 'All'}`; + deviceFilterBtn.classList.toggle('ghost', !desktopOnly); + load(); + }); + + // Delegated rather than attached per-row - resultsEl's rows get + // replaced wholesale on every load(), so a listener attached to a + // specific row would be gone the next time the mode changes. + resultsEl.addEventListener('click', e => { + const btn = e.target.closest('[data-username]'); + if (btn) onShowPublicProfile(btn.dataset.username); + }); + + load(); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowFriends, onShowMultiplayer, onShowStore }); + + return () => { + cleanupTheme(); + cleanupRail(); + }; +} diff --git a/web/src/screens/lyricsScreen.js b/web/src/screens/lyricsScreen.js new file mode 100644 index 0000000..ac8b4f4 --- /dev/null +++ b/web/src/screens/lyricsScreen.js @@ -0,0 +1,127 @@ +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { api, apiUrl, ApiError } from '../api.js'; +import { getUser } from '../auth.js'; +import { parseCustomContent } from '../customText.js'; +import { escapeHtml } from '../util.js'; + +const POLL_INTERVAL_MS = 4000; + +// Connect Spotify -> poll what's currently playing -> fetch synced lyrics +// for it -> hand the result to onLyricsReady, which app.js implements by +// setting it as the active custom text and starting a game exactly like a +// manually-uploaded .lrc file would - this screen only produces that same +// {name, chunks, language, timed} shape, it doesn't play anything itself. +export function renderLyricsScreen(root, { onBack, onLyricsReady, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + let status = 'checking'; + let message = 'Checking sign-in...'; + let lastTrackKey = null; + let pollTimer = null; + let stopped = false; + + function bodyText() { + if (status === 'signed-out') return 'Sign in first - the Spotify connection is tied to your account.'; + if (status === 'not-connected') return 'Connect your Spotify account to type along with whatever you\'re playing.'; + if (status === 'not-configured') return message; + return message; + } + + function render() { + root.innerHTML = ` +
+ +

Lyrics

+
${escapeHtml(bodyText())}
+ ${status === 'signed-out' ? `` : ''} + ${status === 'not-connected' ? `Connect Spotify` : ''} + +
+ `; + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', () => { stopped = true; onBack(); })); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + const goAccount = root.querySelector('[data-action="go-account"]'); + if (goAccount) goAccount.addEventListener('click', () => { stopped = true; onShowAccount(); }); + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + return () => { cleanupTheme(); cleanupRail(); }; + } + + let cleanup = render(); + function rerender() { + if (stopped) return; + cleanup(); + cleanup = render(); + } + + async function poll() { + if (stopped) return; + if (!getUser()) { + status = 'signed-out'; + rerender(); + return; + } + try { + const playing = await api.get('/api/spotify/now-playing'); + if (!playing.is_playing || !playing.track) { + status = 'waiting'; + message = 'Play something on Spotify to get started.'; + rerender(); + } else { + const trackKey = `${playing.artist}::${playing.track}`; + status = 'waiting'; + message = `Now playing: ${playing.track} - ${playing.artist}. Looking for lyrics...`; + rerender(); + if (trackKey !== lastTrackKey) { + lastTrackKey = trackKey; + await fetchLyrics(playing); + } + } + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + status = 'not-connected'; + } else if (err instanceof ApiError && err.status === 401) { + status = 'signed-out'; + } else if (err instanceof ApiError && err.status === 501) { + status = 'not-configured'; + message = err.message; + } else { + status = 'waiting'; + message = 'Could not reach Spotify - retrying...'; + } + rerender(); + } + if (!stopped && status !== 'not-configured') { + pollTimer = setTimeout(poll, POLL_INTERVAL_MS); + } + } + + async function fetchLyrics(playing) { + try { + const durationSeconds = playing.duration_ms ? Math.round(playing.duration_ms / 1000) : undefined; + const query = new URLSearchParams({ artist: playing.artist, track: playing.track }); + if (durationSeconds) query.set('duration', String(durationSeconds)); + const result = await api.get(`/api/lyrics?${query.toString()}`); + const raw = result.lrc || result.plain; + const filename = result.lrc ? 'lyrics.lrc' : 'lyrics.txt'; + const { chunks, language, timed } = parseCustomContent(raw, filename); + if (chunks.length === 0) return; + stopped = true; + if (pollTimer) clearTimeout(pollTimer); + onLyricsReady({ name: `${playing.track} - ${playing.artist}`, chunks, language, timed }); + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + message = `No lyrics found for "${playing.track}" - try a different song.`; + rerender(); + } + } + } + + poll(); + + return () => { + stopped = true; + if (pollTimer) clearTimeout(pollTimer); + cleanup(); + }; +} diff --git a/web/src/screens/mainMenu.js b/web/src/screens/mainMenu.js new file mode 100644 index 0000000..909740d --- /dev/null +++ b/web/src/screens/mainMenu.js @@ -0,0 +1,531 @@ +import { STATS_ICON, SETTINGS_ICON, CHEVRON_DOWN_ICON, LEADERBOARD_ICON, STORE_ICON } from './icons.js'; +import { parseCustomContent } from '../customText.js'; +import { escapeHtml } from '../util.js'; +import { getSettings, updateSettings } from '../settings.js'; +import { getProfileStats } from '../profileStats.js'; +import { attachTooltips } from '../tooltip.js'; +import { wordListTiers } from '../wordGenerator.js'; +import { renderTopRail } from '../topRail.js'; +import { onCountsChange, getCounts } from '../counts.js'; + +function label(m) { + if (m === 'random') return 'Random'; + if (m === 'words') return 'Words'; + // "Time" read as a duplicate of the Rules panel's "Time: 30s" duration + // control - this is the mode (type until the clock runs out), that's a + // setting (how long the clock runs), so they needed visibly different + // names. + if (m === 'time') return 'Timed'; + if (m === 'custom') return 'Custom'; + return m.charAt(0).toUpperCase() + m.slice(1); +} + +const MODE_DESCRIPTIONS = { + random: 'A random passage from any category below.', + words: 'Type a fixed number of random words (set in Rules).', + time: 'Type as many words as you can before the clock runs out (duration set in Rules).', + general: 'Random passages from the general text collection.', + literature: 'Passages from literature.', + programming: 'Passages of real code.', + quotes: 'Short standalone quotes.', + custom: 'Paste or upload your own text to type.', + zen: 'No timer, no word limit - type for as long as you want, then finish whenever you\'re ready.', + practice: 'Words chosen to target the letters you personally mistype or slow down on most, based on your recent tests.', +}; + +function modeDescription(m) { + return MODE_DESCRIPTIONS[m] || ''; +} + +// Rough binary-file detector: a file that decoded to mostly control chars or +// Unicode replacement characters (U+FFFD, inserted for invalid UTF-8 byte +// sequences) is almost certainly not text, even though `.text()` never +// throws on it. +function looksLikeText(str) { + if (!str) return false; + const sample = str.slice(0, 2000); + let suspicious = 0; + for (const ch of sample) { + const code = ch.codePointAt(0); + if (code === 0xfffd || (code < 32 && code !== 9 && code !== 10 && code !== 13)) suspicious++; + } + return suspicious / sample.length < 0.05; +} + +const WORD_COUNTS = [10, 25, 50, 100]; +const TIME_DURATIONS = [15, 30, 60, 120]; +const SOUND_THEMES = ['off', 'click', 'mech']; +function soundThemeLabel(theme) { + if (theme === 'click') return 'Click'; + if (theme === 'mech') return 'Mechanical'; + return 'Off'; +} + +function wordListTierLabel(tier) { + if (tier === 'extended') return 'Extended'; + if (tier === 'hard') return 'Hard'; + return 'Common'; +} + +export function renderMainMenu(root, props) { + const { onStartGame, categories, selectedCategory, onSelectCategory, customText, onLoadCustom, onClearCustom, onStartCustom, onStartPassive, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowLyrics, onShowStore, onSimulateTest } = props; + const modes = ['random', 'words', 'time', 'zen', 'practice', ...categories, 'custom']; + // The picker used to be one flat list of 18 entries mixing two unrelated + // things: how a test is generated (Random/Words/Timed/Zen/Practice/Custom) + // and which body of text it draws from (Literature, Science, ...). Split + // and shown side by side, it fits without scrolling and can be scanned. + const GAME_MODES = ['random', 'words', 'time', 'zen', 'practice']; + const gameModes = [...GAME_MODES.filter(m => modes.includes(m)), 'custom']; + const textCategories = modes.filter(m => !gameModes.includes(m)); + const modeItem = m => ``; + const currentIndex = Math.max(0, modes.indexOf(selectedCategory || 'random')); + const currentMode = modes[currentIndex] || 'random'; + const isCustom = currentMode === 'custom'; + const isWords = currentMode === 'words'; + const isTime = currentMode === 'time'; + + // No "Start:" prefix - it's the one big button in the middle of the + // screen, clicking it obviously starts things; the label's job is just + // to say which mode/text it'll start. + let startLabel = label(currentMode); + if (isCustom) { + startLabel = customText ? customText.name : 'Load Custom Text'; + } + + const settings = getSettings(); + const isFavorite = settings.favoriteMode === currentMode; + const wordCount = WORD_COUNTS.includes(settings.wordCount) ? settings.wordCount : 25; + const timeDuration = TIME_DURATIONS.includes(settings.timeDuration) ? settings.timeDuration : 30; + const profile = getProfileStats(); + + // Shows what Start will actually generate without opening Settings -- + // only Words/Timed have rules to summarize; the quote-based modes and + // Custom don't use word count/punctuation/numbers at all. + let rulesSummary = ''; + if (isWords || isTime) { + const parts = [isWords ? `${wordCount} words` : `${timeDuration}s`]; + if (settings.wordsPunctuation) parts.push('punctuation'); + if (settings.wordsNumbers) parts.push('numbers'); + rulesSummary = parts.join(' · '); + } + + root.innerHTML = ` + + `; + + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + // Live count under the Multiplayer button. The Friends badge is the top + // rail's, painted there. + const mpCaptionEl = root.querySelector('.mp-caption'); + function paintCounts({ online }) { + if (mpCaptionEl) { + mpCaptionEl.hidden = !online; + mpCaptionEl.textContent = online === 1 ? '1 racing now' : `${online} racing now`; + } + } + paintCounts(getCounts()); + const unsubscribeCounts = onCountsChange(paintCounts); + + // Clicking a chevron reveals every mode as a direct pick, rather than + // cycling blind through them one click at a time - Custom and the text + // categories were otherwise invisible unless you clicked through the + // whole list first. Shared between the Singleplayer and Multiplayer + // split-buttons instead of duplicated, since both need the exact same + // "position, open downward, cap to available height" behavior. + const outsideClickHandlers = []; + function setupDropdown(groupEl, chevronEl, popoverEl, hideElWhileOpen) { + function position() { + const rect = groupEl.getBoundingClientRect(); + const gap = 8; + const spaceBelow = window.innerHeight - rect.bottom - gap; + popoverEl.style.left = `${rect.left}px`; + popoverEl.style.width = `${rect.width}px`; + popoverEl.style.top = `${rect.bottom + gap}px`; + popoverEl.style.bottom = 'auto'; + popoverEl.style.maxHeight = `${Math.max(100, spaceBelow)}px`; + } + chevronEl.addEventListener('click', e => { + e.stopPropagation(); + const opening = popoverEl.hidden; + popoverEl.hidden = !popoverEl.hidden; + if (opening) position(); + // The popover opens flush against the button's own bottom edge, + // the same spot this occupies in normal flow - left showing, + // the two sat directly on top of each other. + if (hideElWhileOpen) hideElWhileOpen.style.visibility = opening ? 'hidden' : ''; + }); + const handleOutsideClick = e => { + if (!popoverEl.hidden && !groupEl.contains(e.target)) { + popoverEl.hidden = true; + if (hideElWhileOpen) hideElWhileOpen.style.visibility = ''; + } + }; + document.addEventListener('click', handleOutsideClick); + outsideClickHandlers.push(handleOutsideClick); + } + + setupDropdown( + root.querySelector('.sp-group'), + root.querySelector('.sp-chevron'), + root.querySelector('.sp-popover'), + null, + ); + root.querySelectorAll('.sp-popover .mode-popover-item[data-mode]').forEach(item => { + item.addEventListener('click', () => onSelectCategory(item.dataset.mode)); + }); + root.querySelector('[data-action="mode-lyrics"]').addEventListener('click', onShowLyrics); + + root.querySelector('[data-action="mode-multiplayer"]').addEventListener('click', onShowMultiplayer); + + const startBtn = root.querySelector('.sp-group .start-main'); + const panel = root.querySelector('.custom-panel'); + startBtn.addEventListener('click', () => { + if (isCustom && !customText) { + panel.hidden = false; + return; + } + if (isCustom) { + onStartCustom(); + } else { + onStartGame(); + } + }); + + // Matches the "Enter to start" hint in the bottom-right corner. Skipped + // while typing into a text field or with any popover/panel open, so it + // doesn't hijack Enter from the custom-text textarea, a form, or a mode + // picker where Enter has no obvious meaning. + const handleEnterToStart = e => { + if (e.key !== 'Enter') return; + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA') return; + if (root.querySelector('.mode-popover:not([hidden])')) return; + // The theme gallery is owned by renderThemeButton now, not this + // screen's own state - re-queried fresh rather than kept as a + // local reference, since it doesn't exist until that call runs. + const themePopover = root.querySelector('.theme-gallery-popover'); + if (!settingsPanel.hidden || (themePopover && !themePopover.hidden) || !panel.hidden) return; + startBtn.click(); + }; + document.addEventListener('keydown', handleEnterToStart); + + root.querySelector('[data-action="stats"]').addEventListener('click', onShowStats); + + root.querySelector('[data-action="leaderboard"]').addEventListener('click', onShowLeaderboard); + root.querySelector('[data-action="store"]').addEventListener('click', onShowStore); + // Friends and Account are the top rail's controls now, wired there. + + const settingsPanel = root.querySelector('.settings-panel'); + root.querySelector('[data-action="settings"]').addEventListener('click', () => { + settingsPanel.hidden = false; + }); + root.querySelector('[data-action="close-settings"]').addEventListener('click', () => { + settingsPanel.hidden = true; + }); + root.querySelector('.settings-skip-menu').addEventListener('change', e => { + updateSettings({ skipMenu: e.target.checked }); + }); + + const favoriteBtn = root.querySelector('[data-action="toggle-favorite"]'); + favoriteBtn.addEventListener('click', () => { + const nowFavorite = getSettings().favoriteMode !== currentMode; + updateSettings({ favoriteMode: nowFavorite ? currentMode : null }); + favoriteBtn.textContent = nowFavorite + ? `★ Favorite (${label(currentMode)})` + : `☆ Set "${label(currentMode)}" as favorite`; + }); + + // These update settings in place (text content patched directly) rather + // than going through onSelectCategory's full menu re-render - none of + // them change the selected mode, and a full re-render was resetting + // .settings-panel back to hidden on every single click, forcing you to + // reopen Settings after each change. + // The mode pill above the buttons carries the mode name and its rules. + const startSubEl = root.querySelector('.mode-pill-label'); + function refreshRulesSummary() { + if (!startSubEl) return; + const s = getSettings(); + const parts = [isWords ? `${liveWordCount} words` : `${liveTimeDuration}s`]; + if (s.wordsPunctuation) parts.push('punctuation'); + if (s.wordsNumbers) parts.push('numbers'); + startSubEl.textContent = `${startLabel} · ${parts.join(' · ')}`; + } + + let liveWordCount = wordCount; + const wordCountBtn = root.querySelector('[data-action="cycle-word-count"]'); + if (wordCountBtn) { + wordCountBtn.addEventListener('click', () => { + liveWordCount = WORD_COUNTS[(WORD_COUNTS.indexOf(liveWordCount) + 1) % WORD_COUNTS.length]; + updateSettings({ wordCount: liveWordCount }); + wordCountBtn.textContent = `Words: ${liveWordCount}`; + refreshRulesSummary(); + }); + } + + let liveTimeDuration = timeDuration; + const timeDurationBtn = root.querySelector('[data-action="cycle-time-duration"]'); + if (timeDurationBtn) { + timeDurationBtn.addEventListener('click', () => { + liveTimeDuration = TIME_DURATIONS[(TIME_DURATIONS.indexOf(liveTimeDuration) + 1) % TIME_DURATIONS.length]; + updateSettings({ timeDuration: liveTimeDuration }); + timeDurationBtn.textContent = `Time: ${liveTimeDuration}s`; + refreshRulesSummary(); + }); + } + + const punctuationBtn = root.querySelector('[data-action="toggle-punctuation"]'); + if (punctuationBtn) { + punctuationBtn.addEventListener('click', () => { + const next = !getSettings().wordsPunctuation; + updateSettings({ wordsPunctuation: next }); + punctuationBtn.textContent = `Punctuation: ${next ? 'On' : 'Off'}`; + refreshRulesSummary(); + }); + } + const numbersBtn = root.querySelector('[data-action="toggle-numbers"]'); + if (numbersBtn) { + numbersBtn.addEventListener('click', () => { + const next = !getSettings().wordsNumbers; + updateSettings({ wordsNumbers: next }); + numbersBtn.textContent = `Numbers: ${next ? 'On' : 'Off'}`; + refreshRulesSummary(); + }); + } + const wordListTierBtn = root.querySelector('[data-action="cycle-word-list-tier"]'); + if (wordListTierBtn) { + const tiers = wordListTiers(); + wordListTierBtn.addEventListener('click', () => { + const current = getSettings().wordListTier; + const next = tiers[(tiers.indexOf(current) + 1) % tiers.length]; + updateSettings({ wordListTier: next }); + wordListTierBtn.textContent = `Word List: ${wordListTierLabel(next)}`; + }); + } + + const liveStatsBtn = root.querySelector('[data-action="toggle-live-stats"]'); + liveStatsBtn.addEventListener('click', () => { + const next = !getSettings().hideLiveStats; + updateSettings({ hideLiveStats: next }); + liveStatsBtn.textContent = `Live Stats: ${next ? 'Off' : 'On'}`; + }); + const caretBlinkBtn = root.querySelector('[data-action="toggle-caret-blink"]'); + caretBlinkBtn.addEventListener('click', () => { + const next = !getSettings().caretBlink; + updateSettings({ caretBlink: next }); + caretBlinkBtn.textContent = `Blink Caret: ${next ? 'On' : 'Off'}`; + }); + const blindModeBtn = root.querySelector('[data-action="toggle-blind-mode"]'); + blindModeBtn.addEventListener('click', () => { + const next = !getSettings().blindMode; + updateSettings({ blindMode: next }); + blindModeBtn.textContent = `Blind Mode: ${next ? 'On' : 'Off'}`; + }); + const soundThemeBtn = root.querySelector('[data-action="cycle-sound-theme"]'); + soundThemeBtn.addEventListener('click', () => { + const current = getSettings().soundTheme; + const next = SOUND_THEMES[(SOUND_THEMES.indexOf(current) + 1) % SOUND_THEMES.length]; + updateSettings({ soundTheme: next }); + soundThemeBtn.textContent = `Sound: ${soundThemeLabel(next)}`; + }); + + root.querySelector('[data-action="simulate-test"]').addEventListener('click', onSimulateTest); + + const passiveBtn = root.querySelector('[data-action="start-passive"]'); + if (passiveBtn) passiveBtn.addEventListener('click', onStartPassive); + + const clearBtn = root.querySelector('[data-action="clear-custom"]'); + if (clearBtn) clearBtn.addEventListener('click', onClearCustom); + + const closeBtn = root.querySelector('[data-action="close-custom"]'); + closeBtn.addEventListener('click', () => { panel.hidden = true; }); + + const fileInput = root.querySelector('.custom-file-input'); + const fileNameLabel = root.querySelector('.custom-file-name'); + const textarea = root.querySelector('.custom-textarea'); + const customStats = root.querySelector('.custom-stats'); + const errorBox = root.querySelector('.custom-error'); + let pickedFile = null; + + // Word/character count of whatever's currently loaded (pasted or a + // picked file) - shown before you commit to it, since a file's content + // isn't otherwise visible until you've already started typing it. + async function refreshCustomStats() { + let raw = pickedFile ? await pickedFile.text() : textarea.value; + if (!raw || !raw.trim()) { + customStats.hidden = true; + return; + } + const words = raw.trim().split(/\s+/).filter(Boolean).length; + const chars = raw.length; + customStats.hidden = false; + customStats.textContent = `${words} words · ${chars} characters`; + } + + function setPickedFile(file) { + pickedFile = file || null; + fileNameLabel.textContent = pickedFile ? pickedFile.name : ''; + if (pickedFile) textarea.value = ''; + refreshCustomStats(); + } + + fileInput.addEventListener('change', () => setPickedFile(fileInput.files[0])); + textarea.addEventListener('input', () => { + if (pickedFile) setPickedFile(null); + else refreshCustomStats(); + }); + + // Drag-and-drop a file directly onto the textarea, instead of only + // through the file picker button. + textarea.addEventListener('dragover', e => { + e.preventDefault(); + textarea.classList.add('drag-over'); + }); + textarea.addEventListener('dragleave', () => textarea.classList.remove('drag-over')); + textarea.addEventListener('drop', e => { + e.preventDefault(); + textarea.classList.remove('drag-over'); + const file = e.dataTransfer.files && e.dataTransfer.files[0]; + if (file) setPickedFile(file); + }); + + root.querySelector('[data-action="use-pasted"]').addEventListener('click', async () => { + errorBox.textContent = ''; + try { + let raw; + let name; + if (pickedFile) { + raw = await pickedFile.text(); + name = pickedFile.name; + } else { + raw = textarea.value; + name = 'Pasted text'; + } + if (!raw || !raw.trim()) { + errorBox.textContent = 'Paste some text or choose a file first.'; + return; + } + if (pickedFile && !looksLikeText(raw)) { + errorBox.textContent = "That file doesn't look like text - try a .txt, .md, or code file."; + return; + } + const { chunks, language, timed } = parseCustomContent(raw, name); + if (chunks.length === 0) { + errorBox.textContent = 'Could not find any typeable text in that content.'; + return; + } + onLoadCustom({ name, chunks, language, timed }); + } catch (err) { + console.error('Failed to load custom text:', err); + errorBox.textContent = 'Failed to read that file.'; + } + }); + + return () => { + cleanupTheme(); + unsubscribeCounts(); + document.removeEventListener('keydown', handleEnterToStart); + outsideClickHandlers.forEach(h => document.removeEventListener('click', h)); + }; +} diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js new file mode 100644 index 0000000..0de0182 --- /dev/null +++ b/web/src/screens/multiplayerScreen.js @@ -0,0 +1,265 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { createRoom, quickMatch, connectToRoom } from '../multiplayer.js'; +import { getUser } from '../auth.js'; +import { createGame, freeGame } from '../game.js'; +import { renderTypingGame } from './typingGame.js'; + +// You are always the theme's own accent; opponents take the --player-N hues +// in the order the server lists them, so the mapping is stable for everyone +// in the room and the same person keeps their colour from lobby to race. +const OPPONENT_COLORS = ['var(--player-2)', 'var(--player-3)', 'var(--player-4)', 'var(--player-5)']; + +function racerColors(players, myId) { + const colors = {}; + let next = 0; + for (const p of players) { + if (p.id === myId) { + colors[p.id] = 'var(--primary-color)'; + } else { + colors[p.id] = OPPONENT_COLORS[next % OPPONENT_COLORS.length]; + next += 1; + } + } + return colors; +} + +// No account required - a quick race with friends shouldn't need signing +// in, so this only prefills the name field when one exists rather than +// gating the whole feature behind it (unlike Friends/Leaderboard, which +// inherently need an identity to mean anything). +export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowStore }) { + let connection = null; + let cleanupInner = null; + let players = []; + let myId = null; + let game = null; + + function teardownInner() { + if (cleanupInner) { cleanupInner(); cleanupInner = null; } + } + + function leaveRoom() { + connection?.close(); + connection = null; + } + + function renderLanding(landingError) { + teardownInner(); + let deviceFilter = 'everyone'; + root.innerHTML = ` +
+ +

Multiplayer

+
+ + + +
or race specific people
+
+ + +
+ +
${escapeHtml(landingError || '')}
+
+ +
+ `; + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowStore }); + + const nameInput = root.querySelector('#mp-name'); + const errorEl = root.querySelector('.mp-error'); + + const deviceFilterBtn = root.querySelector('[data-action="toggle-device-filter"]'); + deviceFilterBtn.addEventListener('click', () => { + deviceFilter = deviceFilter === 'everyone' ? 'desktop_only' : 'everyone'; + deviceFilterBtn.textContent = `Match: ${deviceFilter === 'everyone' ? 'Everyone' : 'Desktop only'}`; + }); + + const quickBtn = root.querySelector('[data-action="quick"]'); + quickBtn.addEventListener('click', async () => { + const name = nameInput.value.trim() || 'Player'; + quickBtn.disabled = true; + quickBtn.textContent = 'Finding a race...'; + try { + const roomCode = await quickMatch(deviceFilter); + joinRoom(roomCode, name, true); + } catch { + quickBtn.disabled = false; + quickBtn.textContent = 'Find a Race'; + errorEl.textContent = 'Could not reach matchmaking - try again.'; + } + }); + + root.querySelector('[data-action="create"]').addEventListener('click', async () => { + const name = nameInput.value.trim() || 'Player'; + try { + const roomCode = await createRoom(deviceFilter); + joinRoom(roomCode, name); + } catch { + errorEl.textContent = 'Could not create a room - try again.'; + } + }); + root.querySelector('[data-action="join"]').addEventListener('click', () => { + const name = nameInput.value.trim() || 'Player'; + const code = root.querySelector('#mp-room-code').value.trim().toUpperCase(); + if (!code) { errorEl.textContent = 'Enter a room code.'; return; } + joinRoom(code, name); + }); + + cleanupInner = () => { cleanupTheme(); cleanupRail(); }; + } + + // `auto` marks a quick-match join: the player asked to be put in a race, + // not to sit in a lobby, so they are readied up as soon as the server + // acknowledges the join and the race starts on its own once someone else + // is in. A join by room code stays manual - there the lobby is the point, + // since you are waiting for specific people to arrive. + function joinRoom(roomCode, name, auto = false) { + teardownInner(); + players = []; + myId = null; + connection = connectToRoom(roomCode, name); + renderLobby(roomCode, auto); + } + + function renderLobby(roomCode, auto = false) { + root.innerHTML = ` +
+ +

Room ${escapeHtml(roomCode)}

+
Share this code with whoever you're racing.
+
+ +
+ +
+ `; + root.querySelector('[data-action="menu"]').addEventListener('click', () => { leaveRoom(); onBack(); }); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowStore }); + + const readyBtn = root.querySelector('[data-action="ready"]'); + const listEl = root.querySelector('.mp-player-list'); + function paintPlayers() { + const colors = racerColors(players, myId); + listEl.innerHTML = players.map(p => ` +
+
${escapeHtml(p.name)}${p.id === myId ? ' (you)' : ''}
+
${p.ready ? 'Ready' : 'Not ready'}
+
+ `).join(''); + } + paintPlayers(); + + const offJoined = connection.on('joined', id => { + myId = id; + paintPlayers(); + // Ready is sent once, here, rather than on every playerList -- + // the server starts a countdown as soon as everyone present is + // ready, and re-sending would restart it. + if (auto) { + connection.ready(); + readyBtn.textContent = 'Waiting for players...'; + readyBtn.disabled = true; + } + }); + const offPlayerList = connection.on('playerList', list => { players = list; paintPlayers(); }); + const offCountdown = connection.on('countdown', s => { + root.querySelector('.mp-countdown').textContent = s; + }); + const offStart = connection.on('start', text => startRace(text)); + // The server closes the connection right after an Error (e.g. a + // desktop-only room rejecting a mobile joiner) - 'close' always + // follows, so it's what actually routes back to the landing screen; + // this just remembers the message so that screen can show it + // instead of silently dropping the player back with no explanation. + let lastError = null; + const offError = connection.on('error', msg => { lastError = msg; }); + const offClose = connection.on('close', () => renderLanding(lastError)); + + readyBtn.addEventListener('click', () => { + connection.ready(); + readyBtn.textContent = 'Waiting for players...'; + readyBtn.disabled = true; + }); + root.querySelector('[data-action="leave"]').addEventListener('click', () => { leaveRoom(); renderLanding(); }); + + cleanupInner = () => { + offJoined(); offPlayerList(); offCountdown(); offStart(); offError(); offClose(); + cleanupTheme(); cleanupRail(); + }; + } + + async function startRace(text) { + teardownInner(); + try { + game = await createGame(); + game.set_text(text); + } catch (err) { + console.error('Failed to start multiplayer race:', err); + renderLanding(); + return; + } + + const opponents = document.createElement('div'); + opponents.className = 'mp-opponents'; + const progressById = {}; + function paintOpponents() { + const colors = racerColors(players, myId); + opponents.innerHTML = players.filter(p => p.id !== myId).map(p => ` +
+ ${escapeHtml(p.name)} +
+ ${Math.round(progressById[p.id]?.wpm || 0)} +
+ `).join(''); + } + paintOpponents(); + + const offProgress = connection.on('playerProgress', (id, percent, wpm) => { + progressById[id] = { percent, wpm }; + paintOpponents(); + }); + // Reserved for a future shared-standings view - for now the local + // player just falls through to the normal end screen on their own + // finish, same as any other mode. + const offFinished = connection.on('playerFinished', () => {}); + + const cleanupTyping = renderTypingGame(root, { + game, text, modeKey: undefined, + multiplayer: { connection }, + onFinish: result => { + offProgress(); offFinished(); + freeGame(game); + game = null; + leaveRoom(); + onFinish(result); + }, + onMainMenu: () => { leaveRoom(); onBack(); }, + onRestart: null, + onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, + }); + root.appendChild(opponents); + + cleanupInner = () => { + offProgress(); offFinished(); + cleanupTyping(); + opponents.remove(); + }; + } + + renderLanding(); + return () => { + teardownInner(); + leaveRoom(); + freeGame(game); + }; +} diff --git a/web/src/screens/passiveMode.js b/web/src/screens/passiveMode.js new file mode 100644 index 0000000..e690d3c --- /dev/null +++ b/web/src/screens/passiveMode.js @@ -0,0 +1,159 @@ +import { getTheme, toggleTheme, onThemeChange } from '../theme.js'; +import { themeIcon } from './icons.js'; +import { escapeHtml } from '../util.js'; + +const PACE_WPM = 130; +const LINE_GAP_SECONDS = 0.6; + +function buildSchedule(chunks) { + const allTimed = chunks.every(c => c.time !== null && c.time !== undefined); + if (allTimed) { + const base = chunks[0].time; + return chunks.map(c => c.time - base); + } + let t = 0; + const offsets = []; + for (const c of chunks) { + offsets.push(t); + const duration = (c.content.length / 5) / (PACE_WPM / 60); + t += duration + LINE_GAP_SECONDS; + } + return offsets; +} + +function renderLine(content, typed) { + const inputChars = typed.split(''); + let html = ''; + for (let i = 0; i < content.length; i++) { + const char = content[i]; + const inputChar = inputChars[i]; + let cls = 'neutral'; + let displayChar = char; + if (i < inputChars.length) { + cls = inputChar === char ? 'correct' : 'incorrect'; + if (cls === 'incorrect') displayChar = inputChar; + } + html += `${escapeHtml(displayChar)}`; + } + return html; +} + +export function renderPassiveMode(root, { name, chunks, onExit }) { + const schedule = buildSchedule(chunks); + const totalDuration = schedule[schedule.length - 1] + 4; // let the last line linger + + root.innerHTML = ` +
+ +
${escapeHtml(name)} · passive mode
+
+ +
+ +
+ +
+
ACCURACY
+
100%
+
+ + +
+ `; + + const prevLine = root.querySelector('.passive-line.prev'); + const activeLine = root.querySelector('.passive-line.active'); + const nextLine = root.querySelector('.passive-line.next'); + const input = root.querySelector('.passive-input'); + const accField = root.querySelector('[data-field="acc"]'); + const summary = root.querySelector('.passive-summary'); + const themeButton = root.querySelector('.theme-toggle'); + + const paintTheme = () => { themeButton.innerHTML = themeIcon(getTheme()); }; + paintTheme(); + const unsubscribeTheme = onThemeChange(paintTheme); + themeButton.addEventListener('click', toggleTheme); + root.querySelector('[data-action="menu"]').addEventListener('click', onExit); + + let activeIndex = -1; + let typedForActive = ''; + let totalCorrect = 0; + let totalTyped = 0; + let done = false; + const startedAt = Date.now(); + let intervalId = null; + + function paintLines() { + prevLine.textContent = activeIndex > 0 ? chunks[activeIndex - 1].content : ''; + activeLine.innerHTML = activeIndex >= 0 ? renderLine(chunks[activeIndex].content, typedForActive) : ''; + nextLine.textContent = activeIndex + 1 < chunks.length ? chunks[activeIndex + 1].content : ''; + } + + function paintAccuracy() { + const acc = totalTyped === 0 ? 100 : Math.round((totalCorrect / totalTyped) * 100); + accField.textContent = `${acc}%`; + return acc; + } + + function finishSession() { + if (done) return; + done = true; + if (intervalId) clearInterval(intervalId); + input.disabled = true; + const acc = paintAccuracy(); + summary.querySelector('[data-field="lines"]').textContent = String(chunks.length); + summary.querySelector('[data-field="final-acc"]').textContent = `${acc}%`; + summary.hidden = false; + } + + function advanceTo(index) { + // Score whatever was typed for the outgoing line before moving on. + if (activeIndex >= 0) { + const target = chunks[activeIndex].content; + for (let i = 0; i < typedForActive.length; i++) { + totalTyped++; + if (typedForActive[i] === target[i]) totalCorrect++; + } + } + activeIndex = index; + typedForActive = ''; + input.value = ''; + paintLines(); + paintAccuracy(); + } + + input.addEventListener('input', () => { + if (done || activeIndex < 0) return; + typedForActive = input.value; + activeLine.innerHTML = renderLine(chunks[activeIndex].content, typedForActive); + }); + input.addEventListener('blur', () => { + if (!done) setTimeout(() => input.focus(), 10); + }); + + summary.querySelector('[data-action="back"]').addEventListener('click', onExit); + + setTimeout(() => input.focus(), 50); + advanceTo(0); + + intervalId = setInterval(() => { + if (done) return; + const elapsed = (Date.now() - startedAt) / 1000; + let target = activeIndex; + while (target + 1 < schedule.length && elapsed >= schedule[target + 1]) target++; + if (target !== activeIndex) advanceTo(target); + if (elapsed >= totalDuration) finishSession(); + }, 150); + + return () => { + unsubscribeTheme(); + if (intervalId) clearInterval(intervalId); + }; +} diff --git a/web/src/screens/placeholderScreen.js b/web/src/screens/placeholderScreen.js new file mode 100644 index 0000000..5d652ce --- /dev/null +++ b/web/src/screens/placeholderScreen.js @@ -0,0 +1,31 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; + +// Shared shell for any feature that has a nav entry point but no real screen +// yet (Multiplayer, Friends - Account and Leaderboard have since become +// real screens and moved out of here) - one implementation to swap out per +// feature as each gets built, instead of a copy of the same "coming soon" +// markup per feature. +export function renderPlaceholderScreen(root, { title, description, onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + root.innerHTML = ` +
+ +

${escapeHtml(title)}

+
${escapeHtml(description)}
+ +
+ `; + + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + + return () => { + cleanupTheme(); + cleanupRail(); + }; +} diff --git a/web/src/screens/publicProfileScreen.js b/web/src/screens/publicProfileScreen.js new file mode 100644 index 0000000..5ce50ed --- /dev/null +++ b/web/src/screens/publicProfileScreen.js @@ -0,0 +1,92 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { api, ApiError } from '../api.js'; +import { FLAIR_ICONS } from './icons.js'; + +// modeKey format is set in app.js: "words-25", "time-30", "quote-". +function modeLabel(modeKey) { + const [kind, arg] = modeKey.split('-'); + if (kind === 'words') return `Words · ${arg}`; + if (kind === 'time') return `Time · ${arg}s`; + if (kind === 'quote') return arg === 'random' ? 'Random' : arg.charAt(0).toUpperCase() + arg.slice(1); + return modeKey; +} + +function formatDate(iso) { + const t = Date.parse(iso); + return Number.isNaN(t) ? '' : new Date(t).toLocaleDateString(); +} + +// Read-only - no settings, no actions on someone else's data, just what +// /api/users/:username/public hands back (the same aggregate shape +// /api/stats/me uses for yourself, minus anything private). +export function renderPublicProfileScreen(root, { username, onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + function bodyMarkup(state) { + if (state.status === 'loading') return `
Loading...
`; + if (state.status === 'error') return `
${escapeHtml(state.message)}
`; + const p = state.profile; + return ` +
+ + + + +
+
Joined ${formatDate(p.joined_at)}
+

Personal bests

+ ${p.personal_bests.length > 0 ? ` +
+ ${p.personal_bests.map(b => ` +
+
${escapeHtml(modeLabel(b.mode_key))}
+
${Math.round(b.wpm)} wpm
+
${formatDate(b.date)}
+
+ `).join('')} +
+ ` : `
No personal bests recorded yet.
`} + `; + } + + root.innerHTML = ` +
+ +

${escapeHtml(username)}

+
+ +
+ `; + + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const bodyEl = root.querySelector('.public-profile-body'); + const headingEl = root.querySelector('.public-profile-heading'); + bodyEl.innerHTML = bodyMarkup({ status: 'loading' }); + api.get(`/api/users/${encodeURIComponent(username)}/public`) + .then(profile => { + bodyEl.innerHTML = bodyMarkup({ status: 'ok', profile }); + if (profile.flair && FLAIR_ICONS[profile.flair]) { + headingEl.innerHTML = `${escapeHtml(username)}${FLAIR_ICONS[profile.flair]}`; + // Scoped to headingEl, not the whole root - see the same + // note in leaderboardScreen.js's load(). + attachTooltips(headingEl); + } + }) + .catch(err => { + const message = err instanceof ApiError && err.status === 404 + ? `No profile found for "${username}".` + : 'Could not load this profile - try again.'; + bodyEl.innerHTML = bodyMarkup({ status: 'error', message }); + }); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + + return () => { + cleanupTheme(); + cleanupRail(); + }; +} diff --git a/web/src/screens/statsScreen.js b/web/src/screens/statsScreen.js new file mode 100644 index 0000000..a38e193 --- /dev/null +++ b/web/src/screens/statsScreen.js @@ -0,0 +1,85 @@ +import { getProfileStats } from '../profileStats.js'; +import { getAllPersonalBests } from '../pb.js'; +import { escapeHtml } from '../util.js'; +import { drawProgressChart } from '../chart.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; + +// modeKey format is set in app.js: "words-25", "time-30", "quote-". +function modeLabel(modeKey) { + const [kind, arg] = modeKey.split('-'); + if (kind === 'words') return `Words · ${arg}`; + if (kind === 'time') return `Time · ${arg}s`; + if (kind === 'quote') return arg === 'random' ? 'Random' : arg.charAt(0).toUpperCase() + arg.slice(1); + return modeKey; +} + +function formatDate(ts) { + if (!ts) return ''; + return new Date(ts).toLocaleDateString(); +} + +export function renderStatsScreen(root, { onBack, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + const profile = getProfileStats(); + const bests = getAllPersonalBests(); + + root.innerHTML = ` +
+ +

Stats

+ + ${profile.testsCompleted > 0 ? ` +
+ + + + + + +
+ ` : `
No tests completed yet - finish a test to start tracking stats.
`} + +

Personal bests

+ ${bests.length > 0 ? ` +
+ ${bests.map(b => ` +
+
${escapeHtml(modeLabel(b.modeKey))}
+
${Math.round(b.wpm)} wpm
+
${formatDate(b.date)}
+
+ `).join('')} +
+ ` : `
No personal bests recorded yet.
`} + +

Progress

+ ${profile.history.length > 1 + ? `
` + : `
Complete a few more tests to see a progress chart.
`} + + +
+ `; + + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const canvas = root.querySelector('.stats-chart canvas'); + let cleanupResize = () => {}; + if (canvas) { + const redraw = () => drawProgressChart(canvas, profile.history); + redraw(); + window.addEventListener('resize', redraw); + cleanupResize = () => window.removeEventListener('resize', redraw); + } + + const cleanupRail = renderCornerRail(root, { onShowStats: null, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + + return () => { + cleanupResize(); + cleanupTheme(); + cleanupRail(); + }; +} diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js new file mode 100644 index 0000000..1ab2f84 --- /dev/null +++ b/web/src/screens/storeScreen.js @@ -0,0 +1,145 @@ +import { escapeHtml } from '../util.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { renderTopRail } from '../topRail.js'; +import { api, ApiError } from '../api.js'; +import { getUser } from '../auth.js'; +import { FLAIR_ICONS } from './icons.js'; + +function formatPrice(cents) { + return `$${(cents / 100).toFixed(2)}`; +} + +function categoryLabel(category) { + return category === 'caret' ? 'Caret Colors' : 'Flair'; +} + +// Real purchase/equip flow against the catalog, but purchase is a stub -- +// it grants ownership immediately with no actual charge, since real payment +// processing needs the project owner's own Stripe (or other processor) +// account. Parked as a question rather than assumed: see nightshift +// questions. +export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer }) { + let catalog = []; + let mine = { owned: [], equipped_caret: null, equipped_flair: null }; + let status = 'loading'; + let message = ''; + let stopped = false; + + function itemMarkup(item) { + const owned = mine.owned.includes(item.id); + const equipped = mine.equipped_caret === item.id || mine.equipped_flair === item.id; + const swatch = item.category === 'caret' + ? `` + : `${FLAIR_ICONS[item.value] || ''}`; + return ` +
+ ${swatch} +
${escapeHtml(item.name)}
+ ${!owned ? `
${formatPrice(item.price_cents)}
` : ''} + ${owned + ? `` + : ``} +
+ `; + } + + function bodyMarkup() { + if (status === 'signed-out') return `
Sign in to buy and equip cosmetics.
`; + if (status === 'loading') return `
Loading...
`; + if (status === 'error') return `
${escapeHtml(message)}
`; + + const categories = ['caret', 'flair']; + return categories.map(cat => { + const items = catalog.filter(i => i.category === cat); + if (items.length === 0) return ''; + return `

${categoryLabel(cat)}

${items.map(itemMarkup).join('')}
`; + }).join('') + `
`; + } + + function render() { + root.innerHTML = ` +
+ +

Store

+ ${bodyMarkup()} + +
+ `; + root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); + attachTooltips(root); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); + + const goAccount = root.querySelector('[data-action="go-account"]'); + if (goAccount) goAccount.addEventListener('click', onShowAccount); + + const errorEl = root.querySelector('.store-error'); + root.querySelectorAll('[data-action="buy"]').forEach(btn => { + btn.addEventListener('click', async () => { + try { + await api.post(`/api/cosmetics/${btn.dataset.id}/purchase`); + await loadMine(); + rerender(); + } catch (err) { + if (errorEl) errorEl.textContent = err instanceof ApiError ? err.message : 'Could not complete the purchase - try again.'; + } + }); + }); + root.querySelectorAll('[data-action="equip"]').forEach(btn => { + btn.addEventListener('click', async () => { + await api.post(`/api/cosmetics/${btn.dataset.id}/equip`).catch(() => {}); + await loadMine(); + rerender(); + }); + }); + root.querySelectorAll('[data-action="unequip"]').forEach(btn => { + btn.addEventListener('click', async () => { + await api.post('/api/cosmetics/unequip', { category: btn.dataset.category }).catch(() => {}); + await loadMine(); + rerender(); + }); + }); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer }); + return () => { cleanupTheme(); cleanupRail(); }; + } + + let cleanup; + function rerender() { + if (stopped) return; + if (cleanup) cleanup(); + cleanup = render(); + } + + async function loadCatalog() { + try { + catalog = await api.get('/api/cosmetics'); + } catch { + status = 'error'; + message = 'Could not load the store - try again.'; + } + } + + async function loadMine() { + if (!getUser()) { status = 'signed-out'; return; } + try { + mine = await api.get('/api/cosmetics/me'); + status = 'ok'; + } catch { + status = 'error'; + message = 'Could not load your cosmetics - try again.'; + } + } + + cleanup = render(); + (async () => { + await loadCatalog(); + await loadMine(); + rerender(); + })(); + + return () => { + stopped = true; + if (cleanup) cleanup(); + }; +} diff --git a/web/src/screens/typingGame.js b/web/src/screens/typingGame.js new file mode 100644 index 0000000..72442a2 --- /dev/null +++ b/web/src/screens/typingGame.js @@ -0,0 +1,309 @@ +import { calculateStats, emptyStats } from '../stats.js'; +import { escapeHtml } from '../util.js'; +import { highlightClasses } from '../customText.js'; +import { getSettings } from '../settings.js'; +import { renderCornerRail } from '../cornerRail.js'; +import { renderTopRail } from '../topRail.js'; +import { attachTooltips } from '../tooltip.js'; +import { playKeySound } from '../keySounds.js'; +import { recordKeystroke } from '../keyStats.js'; +import { getUser } from '../auth.js'; +import { api } from '../api.js'; + +export function renderTypingGame(root, { game, text, attribution, language, progress, timeLimit, modeKey, zenMode, multiplayer, onFinish, onMainMenu, onRestart, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { + const syntaxClasses = language ? highlightClasses(text, language) : null; + const { hideLiveStats, caretBlink, blindMode } = getSettings(); + root.innerHTML = ` + + `; + + const textDisplay = root.querySelector('.text-display'); + const input = root.querySelector('.typing-input'); + const wpmField = root.querySelector('[data-field="wpm"]'); + const accField = root.querySelector('[data-field="acc"]'); + const timeField = root.querySelector('[data-field="time"]'); + + attachTooltips(root); + // Restart and Zen Finish used to fixed-position themselves at hardcoded + // right offsets (4.25rem and 7.5rem) that assumed the theme toggle was the + // only thing to their right. The top rail is wider than that now, so they + // are handed to it as row members instead of guessing where the row ends. + const railExtras = []; + const zenBtn = root.querySelector('[data-action="zen-finish"]'); + if (zenBtn) railExtras.push(zenBtn); + const restartBtn = root.querySelector('[data-action="restart"]'); + if (restartBtn) railExtras.push(restartBtn); + const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends, extras: railExtras }); + + // Equipped caret color (see cosmetics.rs) overrides the theme's default + // --caret-color on this screen only, via the CSS custom property the + // caret's ::after rule already reads - no equipped caret just leaves + // the theme default in place. + let caretColorTorndown = false; + if (getUser()) { + Promise.all([api.get('/api/cosmetics/me'), api.get('/api/cosmetics')]) + .then(([mine, catalog]) => { + if (caretColorTorndown || !mine.equipped_caret) return; + const item = catalog.find(i => i.id === mine.equipped_caret); + if (item && item.value) { + root.querySelector('.typing-game')?.style.setProperty('--caret-color', item.value); + } + }) + .catch(() => {}); + } + + root.querySelector('[data-action="menu"]').addEventListener('click', onMainMenu); + root.querySelector('[data-action="restart"]')?.addEventListener('click', () => onRestart()); + + let finished = false; + let startTime = null; + let keystrokes = []; + let charTimings = []; + let intervalId = null; + // Persistent, never-decremented count of every forward keystroke made, + // including ones later backspaced away - feeds raw WPM (see stats.js). + let totalKeystrokes = 0; + let lastInputLength = 0; + // Accuracy from the wasm engine is cumulative (tracks every mistake ever + // made, even ones since backspaced away) - the interval below has no + // fresh keystroke to read it from, so it needs its own last-known copy + // instead of falling back to stats.js's current-input-only calculation, + // which forgets a mistake the moment it's corrected. + let lastAccuracy = 100; + // Idle/AFK timeout: a test left running with no keystrokes for this long + // auto-finishes with whatever was typed so far, instead of sitting open + // indefinitely. Applies to every standard mode (quote/words/time) -- + // there's no reason a stalled tab should keep accumulating elapsed time + // and (before the chart's own bucket cap) inflating the graph. + const IDLE_TIMEOUT_MS = 180000; + let lastActivityTime = null; + + function renderText(currentInput) { + const inputChars = currentInput.split(''); + const wordMatches = text.match(/[^\s]+\s*/g) || []; + let charIndex = 0; + const html = []; + for (const word of wordMatches) { + html.push(''); + for (const char of word) { + const inputChar = inputChars[charIndex]; + let className = 'neutral'; + let displayChar = char; + if (charIndex < inputChars.length) { + // Blind Mode: no correct/incorrect feedback while typing + // (not even which character was actually typed for a + // mistake) - the WASM engine still tracks correctness + // normally underneath for accuracy/stats, this only + // changes what gets rendered live. Revealed as usual on + // the end screen, which does its own independent diff. + if (blindMode) { + className = 'typed'; + } else { + className = inputChar === char ? 'correct' : 'incorrect'; + if (className === 'incorrect') displayChar = inputChar; + } + } else if (syntaxClasses && syntaxClasses[charIndex]) { + className += ` ${syntaxClasses[charIndex]}`; + } + if (!finished && charIndex === currentInput.length) className += ' current'; + html.push(`${escapeHtml(displayChar)}`); + charIndex++; + } + html.push(''); + } + textDisplay.innerHTML = html.join(''); + } + + let lastKeystrokeAt = null; + function recordChar(index, isCorrect, time) { + keystrokes.push({ time, index, isCorrect }); + charTimings = charTimings.slice(0, index); + charTimings.push({ time, isCorrect, index }); + + // Skips the very first keystroke of the test - "time since start" + // isn't a meaningful per-key latency the way "time since the last + // keystroke" is for every one after it. + if (lastKeystrokeAt != null) { + recordKeystroke(text[index], isCorrect, (time - lastKeystrokeAt) * 1000); + } + lastKeystrokeAt = time; + } + + function liveStats(currentInput) { + if (!startTime) return emptyStats(); + return calculateStats(currentInput, text, (Date.now() - startTime) / 1000, totalKeystrokes); + } + + function paintStats(stats, accuracy) { + wpmField.textContent = String(Math.round(stats.wpm)); + accField.textContent = `${Math.round(accuracy ?? stats.accuracy)}%`; + // Time mode counts down to zero instead of counting up, since the + // test ends on the clock rather than at the end of the text. + timeField.textContent = timeLimit + ? Math.max(0, timeLimit - stats.time).toFixed(1) + : stats.time.toFixed(1); + + // Single hook point for every caller of paintStats (each keystroke, + // the 100ms live-stats interval, backspace) rather than scattering a + // sendProgress call at each call site. + if (multiplayer && stats.totalChars > 0) { + const percent = ((stats.correctChars + stats.incorrectChars) / stats.totalChars) * 100; + multiplayer.connection.sendProgress(percent, stats.wpm); + } + } + + function finish(accuracy, mistakes, finalInput) { + finished = true; + input.disabled = true; + if (intervalId) clearInterval(intervalId); + const elapsed = startTime ? (Date.now() - startTime) / 1000 : 0; + // Time/Zen mode's text is a long word buffer sized to outlast the + // clock (or, for Zen, just sized generously since there's no clock + // at all) - trim it to what was actually reached so the end screen + // doesn't try to render hundreds of untyped words. + const finalText = (timeLimit || zenMode) ? text.slice(0, finalInput.length) : text; + const stats = calculateStats(finalInput, finalText, elapsed, totalKeystrokes); + stats.accuracy = accuracy; + stats.incorrectChars = mistakes; + if (multiplayer) multiplayer.connection.sendFinish(stats.wpm, accuracy, elapsed); + onFinish({ stats, text: finalText, attribution, userInput: finalInput, charTimings, keypressHistory: keystrokes, modeKey }); + } + + function handleChange() { + if (finished || !game) return; + const value = input.value; + if (value.length > text.length) { + input.value = value.slice(0, text.length); + return; + } + if (!startTime) startTime = Date.now(); + lastActivityTime = Date.now(); + const elapsed = (Date.now() - startTime) / 1000; + + try { + game.handle_input(value); + const [wasmInput, accuracy, mistakes] = game.get_stats_and_input(); + input.value = wasmInput; + if (wasmInput.length > lastInputLength) { + totalKeystrokes += wasmInput.length - lastInputLength; + playKeySound(); + } + lastInputLength = wasmInput.length; + if (wasmInput.length > 0) { + const idx = wasmInput.length - 1; + recordChar(idx, wasmInput[idx] === text[idx], elapsed); + } + renderText(wasmInput); + lastAccuracy = accuracy; + paintStats(liveStats(wasmInput), accuracy); + + if (game.is_finished()) { + finish(accuracy, mistakes, wasmInput); + } + } catch (err) { + console.error('WASM input error:', err); + } + } + + async function handleKeydown(e) { + if (finished || !game) return; + if (e.key === 'Tab') { + // Quick restart: abandon the current attempt and start a fresh + // one of the same mode, without reaching for the mouse. + e.preventDefault(); + if (onRestart) onRestart(); + return; + } + const ctrlOrMeta = e.ctrlKey || e.metaKey; + // Some platforms bind native readline-style line-editing to text + // inputs (Ctrl+U "clear to start of line", Ctrl+K "clear to end"). + // Left unblocked, these silently wipe the whole typed input via the + // browser's own text-field editing, bypassing our backspace rules + // entirely - block them outright rather than let them fire. + if (ctrlOrMeta && (e.key === 'u' || e.key === 'U' || e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + return; + } + if (e.key !== 'Backspace') return; + e.preventDefault(); + try { + const ctrl = ctrlOrMeta; + const success = await game.handle_backspace(ctrl); + if (success) { + lastActivityTime = Date.now(); + const [wasmInput, accuracy] = await game.get_stats_and_input(); + input.value = wasmInput; + lastInputLength = wasmInput.length; + charTimings = charTimings.slice(0, wasmInput.length); + renderText(wasmInput); + lastAccuracy = accuracy; + paintStats(liveStats(wasmInput), accuracy); + } + } catch (err) { + console.error('WASM backspace error:', err); + } + } + + input.addEventListener('input', handleChange); + input.addEventListener('keydown', handleKeydown); + input.addEventListener('blur', () => { + if (!finished) setTimeout(() => input.focus(), 10); + }); + + // Zen has no timer and no fixed length, so unlike every other mode it + // never ends on its own - this is the only way to actually finish one. + const zenFinishBtn = root.querySelector('[data-action="zen-finish"]'); + if (zenFinishBtn) { + zenFinishBtn.addEventListener('click', () => { + if (finished || !game || !startTime) return; + const [wasmInput, accuracy, mistakes] = game.get_stats_and_input(); + finish(accuracy, mistakes, wasmInput); + }); + } + + renderText(''); + paintStats(emptyStats(), 100); + setTimeout(() => input.focus(), 50); + + intervalId = setInterval(() => { + if (finished || !startTime) return; + const elapsed = (Date.now() - startTime) / 1000; + if (timeLimit && elapsed >= timeLimit) { + const [wasmInput, accuracy, mistakes] = game.get_stats_and_input(); + finish(accuracy, mistakes, wasmInput); + return; + } + if (lastActivityTime && Date.now() - lastActivityTime >= IDLE_TIMEOUT_MS) { + const [wasmInput, accuracy, mistakes] = game.get_stats_and_input(); + finish(accuracy, mistakes, wasmInput); + return; + } + paintStats(liveStats(input.value), lastAccuracy); + }, 100); + + const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); + // No Tab-restart shortcut mid-multiplayer-race (onRestart is null there, + // same guard the restart-toggle button itself uses) - falls back to + // the plain version stamp instead of advertising a shortcut that does + // nothing. + + return () => { + caretColorTorndown = true; + cleanupTheme(); + if (intervalId) clearInterval(intervalId); + cleanupRail(); + }; +} -- cgit v1.2.3