diff options
| author | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
| commit | a726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch) | |
| tree | 4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /web/src/screens | |
| parent | 8c95e90fa54db565919ec323818f77e7256812c8 (diff) | |
| download | typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.tar.gz typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.zip | |
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.
Diffstat (limited to 'web/src/screens')
| -rw-r--r-- | web/src/screens/accountScreen.js | 121 | ||||
| -rw-r--r-- | web/src/screens/endScreen.js | 226 | ||||
| -rw-r--r-- | web/src/screens/friendsScreen.js | 149 | ||||
| -rw-r--r-- | web/src/screens/icons.js | 109 | ||||
| -rw-r--r-- | web/src/screens/leaderboardScreen.js | 120 | ||||
| -rw-r--r-- | web/src/screens/lyricsScreen.js | 127 | ||||
| -rw-r--r-- | web/src/screens/mainMenu.js | 531 | ||||
| -rw-r--r-- | web/src/screens/multiplayerScreen.js | 265 | ||||
| -rw-r--r-- | web/src/screens/passiveMode.js | 159 | ||||
| -rw-r--r-- | web/src/screens/placeholderScreen.js | 31 | ||||
| -rw-r--r-- | web/src/screens/publicProfileScreen.js | 92 | ||||
| -rw-r--r-- | web/src/screens/statsScreen.js | 85 | ||||
| -rw-r--r-- | web/src/screens/storeScreen.js | 145 | ||||
| -rw-r--r-- | web/src/screens/typingGame.js | 309 |
14 files changed, 2469 insertions, 0 deletions
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 `<div class="account-spotify-status">Checking...</div>`; + if (spotifyConnected) return `<div class="account-spotify-status">Connected</div>`; + return `<a class="menu-button small" href="${apiUrl('/api/spotify/login')}">Connect Spotify</a>`; + } + + function formMarkup(user) { + if (user) { + return ` + <div class="account-signed-in">Signed in as <strong>${escapeHtml(user.username)}</strong></div> + <button class="menu-button" data-action="logout">Log Out</button> + <div class="settings-hint">Spotify</div> + ${spotifyStatusMarkup()} + `; + } + return ` + <div class="account-tabs"> + <button class="menu-button small${mode === 'login' ? '' : ' ghost'}" data-action="tab-login">Log In</button> + <button class="menu-button small${mode === 'register' ? '' : ' ghost'}" data-action="tab-register">Register</button> + </div> + <form class="account-form"> + <input class="account-input" type="text" name="username" placeholder="Username" autocomplete="username" required> + <input class="account-input" type="password" name="password" placeholder="Password" autocomplete="${mode === 'login' ? 'current-password' : 'new-password'}" required> + <button class="menu-button" type="submit">${mode === 'login' ? 'Log In' : 'Create Account'}</button> + <div class="custom-error account-error"></div> + </form> + `; + } + + function render() { + const user = getUser(); + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Account</h2> + <div class="account-panel">${formMarkup(user)}</div> + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + + 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 + ? '<div class="pb-badge pb-new">NEW BEST</div>' + : `<div class="pb-badge">PB ${Math.round(previous)}</div>`; + + root.innerHTML = ` + <div class="end-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <div class="end-screen-text"><div class="text-display"></div></div> + ${attribution ? `<div class="attribution end-screen-attribution">— ${escapeHtml(attribution)}</div>` : ''} + <div class="end-screen-graph-row"> + <div class="endscreen-side-stat wpm"> + <div class="stat-label">WPM</div><div class="stat-value">${Math.round(stats.wpm)}</div> + ${pbLine} + </div> + <div class="graph-container"><canvas></canvas></div> + <div class="endscreen-side-stat acc"> + <div class="stat-label">ACC</div><div class="stat-value">${Math.round(stats.accuracy)}%</div> + </div> + </div> + <div class="end-screen-stat-row"> + <div class="end-stat"><div class="stat-label">RAW</div><div class="stat-value">${Math.round(stats.rawWpm)}</div></div> + <div class="end-stat"><div class="stat-label">CONSISTENCY</div><div class="stat-value">${consistency}%</div></div> + <div class="end-stat"><div class="stat-label">KEYSTROKES</div><div class="stat-value">${stats.keystrokes}</div></div> + <div class="end-stat" data-tooltip="Correct / wrong / typed past the end / left untyped"> + <div class="stat-label">CHARACTERS</div><div class="stat-value">${stats.correctChars}/${stats.incorrectChars}/${extraChars}/${missedChars}</div> + </div> + <div class="end-stat"><div class="stat-label">TIME</div><div class="stat-value">${stats.time.toFixed(1)}s</div></div> + </div> + <div class="end-screen-buttons"> + <button class="end-screen-button" data-action="again">Play Again</button> + </div> + </div> + `; + + 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('<span>'); + 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(`<span class="${className}">${escapeHtml(displayChar)}</span>`); + charIndex++; + } + html.push('</span>'); + } + 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 `<div class="stats-empty">Sign in to add friends and compare stats.</div> + <button class="menu-button" data-action="go-account">Sign In</button>`; + } + + function signedInMarkup() { + return ` + <form class="account-form friends-add-form"> + <input class="account-input" type="text" name="username" placeholder="Add by username" required> + <button class="menu-button small" type="submit">Send Request</button> + <div class="custom-error friends-add-error"></div> + </form> + + ${data.incoming_requests.length > 0 ? ` + <h3>Requests</h3> + <div class="leaderboard-list"> + ${data.incoming_requests.map(r => ` + <div class="leaderboard-row"> + <div class="leaderboard-name">${escapeHtml(r.username)}</div> + <button class="menu-button small" data-action="accept" data-id="${r.friendship_id}">Accept</button> + <button class="menu-button small ghost" data-action="decline" data-id="${r.friendship_id}">Decline</button> + </div> + `).join('')} + </div>` : ''} + + ${data.outgoing_requests.length > 0 ? ` + <h3>Sent</h3> + <div class="leaderboard-list"> + ${data.outgoing_requests.map(r => ` + <div class="leaderboard-row"> + <div class="leaderboard-name">${escapeHtml(r.username)}</div> + <div class="leaderboard-acc">Pending</div> + <button class="menu-button small ghost" data-action="cancel" data-id="${r.friendship_id}">Cancel</button> + </div> + `).join('')} + </div>` : ''} + + <h3>Friends</h3> + ${data.friends.length > 0 ? ` + <div class="leaderboard-list"> + ${data.friends.map(f => ` + <div class="leaderboard-row"> + <div class="leaderboard-name">${escapeHtml(f.username)}</div> + <button class="menu-button small ghost" data-action="unfriend" data-id="${f.friendship_id}">Remove</button> + </div> + `).join('')} + </div>` : `<div class="stats-empty">No friends yet - add one by username above.</div>`} + + ${loadError ? `<div class="custom-error">${escapeHtml(loadError)}</div>` : ''} + `; + } + + function render() { + const user = getUser(); + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Friends</h2> + ${user ? signedInMarkup() : signedOutMarkup()} + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + + 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> + <rect x="8.6" y="8.6" width="6.8" height="6.8" transform="rotate(45 12 12)"/> + <path d="M12 1v3M12 20v3M1 12h3M20 12h3M4.75 4.75l2.1 2.1M17.15 17.15l2.1 2.1M19.25 4.75l-2.1 2.1M7.15 17.15l-2.1 2.1"/> +</svg>`; + +// 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M12 2L20 6.5V17.5L12 22L4 17.5V6.5L12 2ZM14.3 5.6L19 8.3V15.7L14.3 18.4L16.2 12L14.3 5.6Z"/> +</svg>`; + +// 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <rect x="3" y="13" width="4" height="8"/> + <rect x="10" y="8" width="4" height="13"/> + <rect x="17" y="3" width="4" height="18"/> +</svg>`; + +// 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> + <line x1="3" y1="6" x2="21" y2="6"/> + <rect x="13" y="4" width="4" height="4" fill="currentColor" stroke="none"/> + <line x1="3" y1="12" x2="21" y2="12"/> + <rect x="6" y="10" width="4" height="4" fill="currentColor" stroke="none"/> + <line x1="3" y1="18" x2="21" y2="18"/> + <rect x="16" y="16" width="4" height="4" fill="currentColor" stroke="none"/> +</svg>`; + +// 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M7 3L12.5 13H1.5L7 3Z"/> + <path d="M17 3L22.5 13H11.5L17 3Z" opacity="0.55"/> +</svg>`; + +export const LEADERBOARD_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M6 2H18V9C18 13 15.5 15.5 12 15.5C8.5 15.5 6 13 6 9V2Z"/> + <rect x="10" y="15.5" width="4" height="3"/> + <rect x="6.5" y="18.5" width="11" height="2.5"/> +</svg>`; + +// 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M8 2L11 3.9V7.5L8 9.4L5 7.5V3.9L8 2Z"/> + <path d="M2 21L3.6 14.5H12.4L14 21H2Z"/> + <path d="M16.5 5L19.3 6.7V10L16.5 11.7L13.7 10V6.7L16.5 5Z" opacity="0.55"/> + <path d="M11 22L12.4 16.2H20.6L22 22H11Z" opacity="0.55"/> +</svg>`; + +export const ACCOUNT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path d="M12 2L16.5 4.6V9.8L12 12.4L7.5 9.8V4.6L12 2Z"/> + <path d="M4 22L6.5 15H17.5L20 22H4Z"/> +</svg>`; + +// 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M11 2H21V12L12 21L3 12L11 2ZM14.5 6H17.5V9H14.5V6Z"/> +</svg>`; + +// 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> + <path d="M5 9L12 16L19 9"/> +</svg>`; + +// 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: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L14.2 9.8L22 12L14.2 14.2L12 22L9.8 14.2L2 12L9.8 9.8Z"/></svg>`, + bolt: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13 2L4 14H11L10 22L20 9H13L14 2Z"/></svg>`, + skull: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L20 6.5V14.5C20 17 18.5 19 16.5 20L15 22H9L7.5 20C5.5 19 4 17 4 14.5V6.5L12 2Z"/><rect x="8" y="12" width="3" height="3"/><rect x="13" y="12" width="3" height="3"/></svg>`, +}; + +// 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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square"> + <path d="M3 5H11"/> + <path d="M7 3V5"/> + <path d="M9.5 8.5C8.5 12 6 14 3.5 15.5"/> + <path d="M5 11C6.5 13.5 8.5 15 10.5 16"/> + <path d="M13 21L17 11L21 21"/> + <path d="M14.4 18H19.6"/> +</svg>`; 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 `<div class="stats-empty">Loading...</div>`; + if (state.status === 'error') return `<div class="stats-empty">Could not load the leaderboard - ${escapeHtml(state.message)}</div>`; + if (state.entries.length === 0) return `<div class="stats-empty">No results yet for this mode - be the first.</div>`; + return ` + <div class="leaderboard-list"> + ${state.entries.map((e, i) => ` + <div class="leaderboard-row"> + <div class="leaderboard-rank">#${i + 1}</div> + <button class="leaderboard-name" data-username="${escapeHtml(e.username)}">${escapeHtml(e.username)}${e.flair && FLAIR_ICONS[e.flair] ? `<span class="leaderboard-flair" data-tooltip="Equipped flair">${FLAIR_ICONS[e.flair]}</span>` : ''}</button> + ${e.device_type === 'mobile' ? `<span class="leaderboard-device-badge" data-tooltip="Typed on a touchscreen device">mobile</span>` : ''} + <div class="leaderboard-wpm">${Math.round(e.wpm)} wpm</div> + <div class="leaderboard-acc">${Math.round(e.accuracy)}%</div> + <div class="leaderboard-date">${formatDate(e.date)}</div> + </div> + `).join('')} + </div> + `; + } + + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Leaderboard</h2> + <div class="leaderboard-modes"> + ${MODES.map(m => `<button class="menu-button small${m.key === selectedMode ? '' : ' ghost'}" data-mode="${m.key}">${escapeHtml(m.label)}</button>`).join('')} + </div> + <button class="menu-button small ghost" data-action="toggle-device-filter" data-tooltip="Mobile results still count for personal stats - this only filters what's shown here.">Devices: All</button> + <div class="leaderboard-results"></div> + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + + 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 = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Lyrics</h2> + <div class="stats-placeholder">${escapeHtml(bodyText())}</div> + ${status === 'signed-out' ? `<button class="menu-button" data-action="go-account">Sign In</button>` : ''} + ${status === 'not-connected' ? `<a class="menu-button" href="${apiUrl('/api/spotify/login')}">Connect Spotify</a>` : ''} + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + 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 => `<button class="mode-popover-item${m === currentMode ? ' active' : ''}" data-mode="${escapeHtml(m)}" data-tooltip="${escapeHtml(modeDescription(m))}">${escapeHtml(label(m))}</button>`; + 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 = ` + <div class="main-menu"> + <h1>TyperPunk</h1> + <div class="menu-options"> + <button class="mode-pill sp-chevron" data-tooltip="Choose what to type"> + <span class="mode-pill-label">${escapeHtml(startLabel)}${rulesSummary ? ` · ${escapeHtml(rulesSummary)}` : ''}</span> + ${CHEVRON_DOWN_ICON} + </button> + <div class="start-group sp-group"> + <button class="menu-button start-main" data-action="start">Single Player</button> + <div class="mode-popover sp-popover" hidden> + <div class="mode-popover-group"> + <div class="mode-popover-heading">Modes</div> + ${gameModes.map(modeItem).join('')} + <button class="mode-popover-item" data-action="mode-lyrics" data-tooltip="Connect Spotify and type along to whatever's playing.">Lyrics</button> + </div> + <div class="mode-popover-group"> + <div class="mode-popover-heading">Text</div> + ${textCategories.map(modeItem).join('')} + </div> + </div> + </div> + <div class="start-group mp-group"> + <button class="menu-button start-main" data-action="mode-multiplayer" data-tooltip="Race other typists live.">Multiplayer</button> + </div> + <div class="start-caption mp-caption" hidden></div> + ${isCustom && customText && customText.timed ? `<button class="menu-button" data-action="start-passive">Passive Mode</button>` : ''} + ${isCustom && customText ? `<button class="menu-button" data-action="clear-custom">Clear Custom Text</button>` : ''} + </div> + + <div class="settings-panel" hidden> + <label class="settings-row"> + <input type="checkbox" class="settings-skip-menu"${settings.skipMenu ? ' checked' : ''} /> + Start directly into a game, skip this menu + </label> + <button class="menu-button small" data-action="toggle-favorite"> + ${isFavorite ? `★ Favorite (${escapeHtml(label(currentMode))})` : `☆ Set "${escapeHtml(label(currentMode))}" as favorite`} + </button> + <div class="settings-columns"> + ${(isWords || isTime) ? ` + <div class="settings-column"> + <div class="settings-hint">Rules</div> + <div class="settings-row-group"> + ${isWords ? `<button class="menu-button small" data-action="cycle-word-count" data-tooltip="How many words the Words mode generates. Click to cycle.">Words: ${wordCount}</button>` : ''} + ${isTime ? `<button class="menu-button small" data-action="cycle-time-duration" data-tooltip="How long the clock runs in Timed mode. Click to cycle.">Time: ${timeDuration}s</button>` : ''} + <button class="menu-button small" data-action="toggle-punctuation" data-tooltip="Include punctuation (commas, periods) in generated text.">Punctuation: ${settings.wordsPunctuation ? 'On' : 'Off'}</button> + <button class="menu-button small" data-action="toggle-numbers" data-tooltip="Include numbers in generated text.">Numbers: ${settings.wordsNumbers ? 'On' : 'Off'}</button> + <button class="menu-button small" data-action="cycle-word-list-tier" data-tooltip="Common (short, everyday words), Extended (adds longer/less frequent words), or Hard (technical and irregularly-spelled words). Click to cycle.">Word List: ${wordListTierLabel(settings.wordListTier)}</button> + </div> + </div>` : ''} + <div class="settings-column"> + <div class="settings-hint">Typing</div> + <div class="settings-row-group"> + <button class="menu-button small" data-action="toggle-live-stats" data-tooltip="Show WPM/ACC while you type, or only reveal them on the end screen.">Live Stats: ${settings.hideLiveStats ? 'Off' : 'On'}</button> + <button class="menu-button small" data-action="toggle-caret-blink" data-tooltip="Make the current-character caret blink, or keep it solid.">Blink Caret: ${settings.caretBlink ? 'On' : 'Off'}</button> + <button class="menu-button small" data-action="toggle-blind-mode" data-tooltip="Hide correct/incorrect coloring while typing - only revealed on the end screen.">Blind Mode: ${settings.blindMode ? 'On' : 'Off'}</button> + <button class="menu-button small" data-action="cycle-sound-theme" data-tooltip="Play a sound on each keystroke. Click to cycle.">Sound: ${escapeHtml(soundThemeLabel(settings.soundTheme))}</button> + </div> + </div> + </div> + <div class="settings-hint">Dev</div> + <button class="menu-button small" data-action="simulate-test" data-tooltip="Jump straight to the end screen with fake results, no typing required.">Simulate Test → End Screen</button> + <button class="menu-button small ghost" data-action="close-settings">Close</button> + </div> + + <div class="custom-panel" hidden> + <div class="custom-hint">Paste text, notes, code, subtitles (.srt/.vtt), or lyrics (.lrc) - typed in order, not randomized. Drag a file in, or:</div> + <textarea class="custom-textarea" placeholder="Paste text, notes, code, or lyrics here... (or drop a file anywhere in this box)"></textarea> + <div class="custom-stats" hidden></div> + <div class="custom-panel-row"> + <label class="menu-button small file-label"> + Choose file + <input type="file" class="custom-file-input" accept=".txt,.md,.srt,.vtt,.lrc,.rs,.js,.ts,.jsx,.tsx,.py,.c,.h,.cpp,.hpp,.go,.java,.cs,.sh" hidden /> + </label> + <span class="custom-file-name"></span> + </div> + <div class="custom-panel-row"> + <button class="menu-button small" data-action="use-pasted">Use this text</button> + <button class="menu-button small ghost" data-action="close-custom">Cancel</button> + </div> + <div class="custom-error"></div> + </div> + + <div class="logo">TyperPunk</div> + + <div class="menu-key-hint">Enter to start · Esc for the menu</div> + + <div class="corner-rail-left"> + <button class="corner-icon-button" data-action="settings" aria-label="Settings" data-tooltip="Settings">${SETTINGS_ICON}</button> + <button class="corner-icon-button" data-action="store" aria-label="Store" data-tooltip="Store">${STORE_ICON}</button> + </div> + + <div class="corner-rail-right"> + ${profile.testsCompleted > 0 ? `<div class="stats-corner-figures" data-tooltip="Your average WPM across every test, and your best single result."> + <div class="stats-corner-avg">avg ${profile.averageWpm}</div> + <div class="stats-corner-pb">pb ${Math.round(profile.bestWpm)}</div> + </div>` : ''} + <button class="corner-icon-button" data-action="stats" aria-label="Stats" data-tooltip="Stats">${STATS_ICON}</button> + <button class="corner-icon-button" data-action="leaderboard" aria-label="Leaderboard" data-tooltip="Leaderboard">${LEADERBOARD_ICON}</button> + </div> + + </div> + `; + + 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 = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Multiplayer</h2> + <div class="account-panel"> + <input class="account-input" type="text" id="mp-name" placeholder="Your name" value="${escapeHtml(getUser()?.username || '')}"> + <button class="menu-button small ghost" data-action="toggle-device-filter" data-tooltip="Who you get matched with, and the setting any room you open uses. Joining by code always uses that room's setting.">Match: Everyone</button> + <button class="menu-button" data-action="quick" data-tooltip="Drops you straight into a race with whoever else is looking. No code to share.">Find a Race</button> + <div class="settings-hint">or race specific people</div> + <div class="mp-code-row"> + <input class="account-input" type="text" id="mp-room-code" placeholder="Room code"> + <button class="menu-button small ghost" data-action="join">Join</button> + </div> + <button class="menu-button small ghost" data-action="create" data-tooltip="Opens an empty room and gives you a code to share.">Create a Room</button> + <div class="custom-error mp-error">${escapeHtml(landingError || '')}</div> + </div> + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + 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 = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Room ${escapeHtml(roomCode)}</h2> + <div class="settings-hint">Share this code with whoever you're racing.</div> + <div class="leaderboard-list mp-player-list"></div> + <button class="menu-button" data-action="ready">Ready</button> + <div class="mp-countdown"></div> + <button class="menu-button ghost" data-action="leave">Leave Room</button> + </div> + `; + 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 => ` + <div class="leaderboard-row" style="--racer-color: ${colors[p.id]}"> + <div class="leaderboard-name"><span class="mp-racer-dot"></span>${escapeHtml(p.name)}${p.id === myId ? ' (you)' : ''}</div> + <div class="leaderboard-acc">${p.ready ? 'Ready' : 'Not ready'}</div> + </div> + `).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 => ` + <div class="mp-opponent-row" style="--racer-color: ${colors[p.id]}"> + <span class="mp-opponent-name">${escapeHtml(p.name)}</span> + <div class="mp-opponent-bar"><div class="mp-opponent-bar-fill" style="width:${(progressById[p.id]?.percent || 0)}%"></div></div> + <span class="mp-opponent-wpm">${Math.round(progressById[p.id]?.wpm || 0)}</span> + </div> + `).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 += `<span class="${cls}">${escapeHtml(displayChar)}</span>`; + } + 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 = ` + <div class="passive-mode"> + <div class="logo" data-action="menu">TyperPunk</div> + <div class="passive-source">${escapeHtml(name)} · passive mode</div> + <div class="passive-lines"> + <div class="passive-line prev"></div> + <div class="passive-line active"></div> + <div class="passive-line next"></div> + </div> + <input class="typing-input passive-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" /> + <div class="passive-stats"> + <div class="stat-label">ACCURACY</div> + <div class="stat-value" data-field="acc">100%</div> + </div> + <div class="passive-summary" hidden> + <div class="passive-summary-title">Session complete</div> + <div class="passive-summary-stats"> + <div><span class="stat-label">LINES</span><span class="stat-value" data-field="lines">0</span></div> + <div><span class="stat-label">ACCURACY</span><span class="stat-value" data-field="final-acc">100%</span></div> + </div> + <button class="menu-button" data-action="back">Back to Menu</button> + </div> + <button class="theme-toggle" data-action="theme" aria-label="Toggle theme"></button> + </div> + `; + + 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 = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>${escapeHtml(title)}</h2> + <div class="stats-placeholder">${escapeHtml(description)}</div> + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + + 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-<category>". +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 `<div class="stats-empty">Loading...</div>`; + if (state.status === 'error') return `<div class="stats-empty">${escapeHtml(state.message)}</div>`; + const p = state.profile; + return ` + <div class="stats-summary"> + <div class="menu-stat"><div class="stat-label">Tests</div><div class="stat-value">${p.tests_completed}</div></div> + <div class="menu-stat"><div class="stat-label">Avg WPM</div><div class="stat-value">${Math.round(p.average_wpm)}</div></div> + <div class="menu-stat"><div class="stat-label">Best WPM</div><div class="stat-value">${Math.round(p.best_wpm)}</div></div> + <div class="menu-stat"><div class="stat-label">Avg Acc</div><div class="stat-value">${Math.round(p.average_accuracy)}%</div></div> + </div> + <div class="stats-placeholder">Joined ${formatDate(p.joined_at)}</div> + <h3>Personal bests</h3> + ${p.personal_bests.length > 0 ? ` + <div class="stats-pb-list"> + ${p.personal_bests.map(b => ` + <div class="stats-pb-row"> + <div class="stats-pb-mode">${escapeHtml(modeLabel(b.mode_key))}</div> + <div class="stats-pb-wpm">${Math.round(b.wpm)} wpm</div> + <div class="stats-pb-date">${formatDate(b.date)}</div> + </div> + `).join('')} + </div> + ` : `<div class="stats-empty">No personal bests recorded yet.</div>`} + `; + } + + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2 class="public-profile-heading">${escapeHtml(username)}</h2> + <div class="public-profile-body"></div> + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + + 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)}<span class="leaderboard-flair" data-tooltip="Equipped flair">${FLAIR_ICONS[profile.flair]}</span>`; + // 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-<category>". +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 = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Stats</h2> + + ${profile.testsCompleted > 0 ? ` + <div class="stats-summary"> + <div class="menu-stat"><div class="stat-label">Tests</div><div class="stat-value">${profile.testsCompleted}</div></div> + <div class="menu-stat"><div class="stat-label">Avg WPM</div><div class="stat-value">${profile.averageWpm}</div></div> + <div class="menu-stat"><div class="stat-label">Best WPM</div><div class="stat-value">${Math.round(profile.bestWpm)}</div></div> + <div class="menu-stat"><div class="stat-label">Avg Acc</div><div class="stat-value">${profile.averageAccuracy}%</div></div> + <div class="menu-stat"><div class="stat-label">Streak</div><div class="stat-value">${profile.streakDays}d</div></div> + <div class="menu-stat"><div class="stat-label">Time Typed</div><div class="stat-value">${Math.round(profile.totalTimeSeconds / 60)}m</div></div> + </div> + ` : `<div class="stats-empty">No tests completed yet - finish a test to start tracking stats.</div>`} + + <h3>Personal bests</h3> + ${bests.length > 0 ? ` + <div class="stats-pb-list"> + ${bests.map(b => ` + <div class="stats-pb-row"> + <div class="stats-pb-mode">${escapeHtml(modeLabel(b.modeKey))}</div> + <div class="stats-pb-wpm">${Math.round(b.wpm)} wpm</div> + <div class="stats-pb-date">${formatDate(b.date)}</div> + </div> + `).join('')} + </div> + ` : `<div class="stats-empty">No personal bests recorded yet.</div>`} + + <h3>Progress</h3> + ${profile.history.length > 1 + ? `<div class="stats-chart"><canvas></canvas></div>` + : `<div class="stats-placeholder">Complete a few more tests to see a progress chart.</div>`} + + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + + 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' + ? `<span class="store-swatch" style="background:${escapeHtml(item.value)}"></span>` + : `<span class="store-swatch store-flair-swatch">${FLAIR_ICONS[item.value] || ''}</span>`; + return ` + <div class="leaderboard-row store-item-row"> + ${swatch} + <div class="leaderboard-name">${escapeHtml(item.name)}</div> + ${!owned ? `<div class="leaderboard-acc">${formatPrice(item.price_cents)}</div>` : ''} + ${owned + ? `<button class="menu-button small${equipped ? '' : ' ghost'}" data-action="${equipped ? 'unequip' : 'equip'}" data-id="${item.id}" data-category="${item.category}" data-tooltip="${equipped ? 'Unequip - back to the default look' : `Replaces whichever ${categoryLabel(item.category).toLowerCase()} you have equipped now`}">${equipped ? 'Equipped' : 'Equip'}</button>` + : `<button class="menu-button small" data-action="buy" data-id="${item.id}">Buy</button>`} + </div> + `; + } + + function bodyMarkup() { + if (status === 'signed-out') return `<div class="stats-empty">Sign in to buy and equip cosmetics.</div><button class="menu-button" data-action="go-account">Sign In</button>`; + if (status === 'loading') return `<div class="stats-empty">Loading...</div>`; + if (status === 'error') return `<div class="stats-empty">${escapeHtml(message)}</div>`; + + const categories = ['caret', 'flair']; + return categories.map(cat => { + const items = catalog.filter(i => i.category === cat); + if (items.length === 0) return ''; + return `<h3>${categoryLabel(cat)}</h3><div class="leaderboard-list">${items.map(itemMarkup).join('')}</div>`; + }).join('') + `<div class="custom-error store-error"></div>`; + } + + function render() { + root.innerHTML = ` + <div class="stats-screen"> + <div class="logo" data-action="menu">TyperPunk</div> + <h2>Store</h2> + ${bodyMarkup()} + <button class="menu-button small ghost" data-action="menu">Back</button> + </div> + `; + 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 = ` + <div class="typing-game${hideLiveStats ? ' hide-live-stats' : ''}${caretBlink ? '' : ' no-caret-blink'}"> + <div class="logo" data-action="menu">TyperPunk</div> + ${progress ? `<div class="segment-progress">${escapeHtml(progress)}</div>` : ''} + <div class="text-container"> + <div class="text-display${language ? ' code' : ''}"></div> + <input class="typing-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" /> + </div> + ${attribution ? `<div class="attribution">- ${escapeHtml(attribution)}</div>` : ''} + <div class="wpm-stat"><div class="stat-label">WPM</div><div class="stat-value" data-field="wpm">0</div></div> + <div class="acc-stat"><div class="stat-label">ACC</div><div class="stat-value" data-field="acc">100%</div></div> + <div class="time-stat"><div class="stat-label">TIME</div><div class="stat-value" data-field="time">0.0</div></div> + ${zenMode ? `<button class="zen-finish-button" data-action="zen-finish">Finish</button>` : ''} + ${onRestart ? `<button class="restart-toggle" data-action="restart" aria-label="Restart" data-tooltip="Restart (Tab)">↻</button>` : ''} + </div> + `; + + 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('<span>'); + 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(`<span class="${className}">${escapeHtml(displayChar)}</span>`); + charIndex++; + } + html.push('</span>'); + } + 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(); + }; +} |