diff --git a/go.mod b/go.mod index aaf0b2f..fa27007 100644 --- a/go.mod +++ b/go.mod @@ -4,21 +4,21 @@ go 1.26.0 require ( github.com/go-playground/validator/v10 v10.30.3 - github.com/gofiber/fiber/v2 v2.52.13 + github.com/gofiber/fiber/v2 v2.52.14 github.com/google/uuid v1.6.0 github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226 - github.com/mattn/go-sqlite3 v1.14.45 + github.com/mattn/go-sqlite3 v1.14.47 golang.org/x/term v0.44.0 ) require ( - github.com/andybalholm/brotli v1.2.1 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/compress v1.19.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.22 // indirect @@ -26,8 +26,8 @@ require ( github.com/philhofer/fwd v1.2.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.71.0 // indirect + github.com/valyala/fasthttp v1.72.0 // indirect golang.org/x/crypto v0.53.0 // indirect golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index c6d55de..72c40c7 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -24,6 +26,8 @@ github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/ github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk= github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofiber/fiber/v2 v2.52.14 h1:Of3L+9qVFaQNwPlcmEdl5IIodHz8BSE0j37R7rWu4pE= +github.com/gofiber/fiber/v2 v2.52.14/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -32,6 +36,8 @@ github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226 h1:c7wfyZGdy6RkM/b6mIazoYrAS+3qDL7d9M1CFm2e1VA= @@ -56,6 +62,8 @@ github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCK github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk= github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= +github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -72,6 +80,8 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= +github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M= +github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= @@ -99,5 +109,7 @@ golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/server/webserver/chess-client-web/app.js b/internal/server/webserver/chess-client-web/app.js index 46095c5..c451aaa 100644 --- a/internal/server/webserver/chess-client-web/app.js +++ b/internal/server/webserver/chess-client-web/app.js @@ -15,6 +15,8 @@ let gameState = { authToken: null, userId: null, username: null, + authBusy: false, + newGameBusy: false, }; // Chess piece Unicode: all black pieces for better fill, white pawn due to inability to override emoji variant display @@ -23,6 +25,26 @@ const pieceMap = { 'P': '♙', 'R': '♜', 'N': '♞', 'B': '♝', 'Q': '♛', 'K': '♚' }; +// How long a success message stays visible in a modal before it auto-closes +const MODAL_SUCCESS_DISPLAY_MS = 700; + +// Shared helpers: show/clear a status line inside a modal. Distinct from +// flashErrorMessage, which is not visible while a modal's backdrop is up. +function setModalMessage(elementId, message, type = 'error') { + const el = document.getElementById(elementId); + if (!el) return; + el.textContent = message; + el.classList.remove('error', 'success'); + el.classList.add('show', type); +} + +function clearModalMessage(elementId) { + const el = document.getElementById(elementId); + if (!el) return; + el.textContent = ''; + el.classList.remove('show', 'error', 'success'); +} + // Initialize on page load document.addEventListener('DOMContentLoaded', async () => { const config = await getConfig(); @@ -115,6 +137,20 @@ function handleAuthClick() { } } +// Disables/enables every interactive control in the auth modal at once, and tracks +// whether a login/register request is in flight (or its success message is showing). +// Guards re-entrancy from the Enter-key handler, which bypasses individual button +// disabled state, and stops the user editing fields or switching tabs mid-request. +function setAuthModalBusy(busy) { + gameState.authBusy = busy; + document.getElementById('login-submit-btn').disabled = busy; + document.getElementById('register-submit-btn').disabled = busy; + document.getElementById('auth-cancel-btn').disabled = busy; + document.getElementById('auth-cancel-btn-2').disabled = busy; + document.querySelectorAll('.auth-tab').forEach(t => t.disabled = busy); + document.querySelectorAll('.auth-form input').forEach(i => i.disabled = busy); +} + function showAuthModal() { document.getElementById('auth-modal-overlay').classList.add('show'); document.getElementById('login-identifier').focus(); @@ -127,12 +163,21 @@ function hideAuthModal() { document.getElementById('auth-modal-overlay').classList.remove('show'); document.querySelectorAll('.auth-form input').forEach(input => input.value = ''); document.removeEventListener('keydown', handleAuthModalKeydown); + clearModalMessage('auth-modal-message'); + setAuthModalBusy(false); } function handleAuthModalKeydown(e) { const modal = document.getElementById('auth-modal-overlay'); if (!modal.classList.contains('show')) return; + // While a request is in flight, block just Enter (re-submit) and Escape + // (close); everything else (Tab, copy shortcuts, etc.) passes through. + if (gameState.authBusy) { + if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault(); + return; + } + if (e.key === 'Escape') { e.preventDefault(); hideAuthModal(); @@ -153,6 +198,7 @@ function switchAuthTab(tab) { document.getElementById('login-form').style.display = tab === 'login' ? 'block' : 'none'; document.getElementById('register-form').style.display = tab === 'register' ? 'block' : 'none'; + clearModalMessage('auth-modal-message'); } // Shared helper: safely parse error response regardless of Content-Type @@ -165,93 +211,125 @@ async function parseErrorResponse(response) { } async function handleLogin() { + if (gameState.authBusy) return; + const identifier = document.getElementById('login-identifier').value.trim(); const password = document.getElementById('login-password').value; if (!identifier || !password) { - flashErrorMessage('Fill all fields'); + setModalMessage('auth-modal-message', 'Fill all fields', 'error'); return; } - const submitBtn = document.getElementById('login-submit-btn'); - submitBtn.disabled = true; + setAuthModalBusy(true); + clearModalMessage('auth-modal-message'); + let response; try { - const response = await fetch(`${gameState.apiUrl}/api/v1/auth/login`, { + response = await fetch(`${gameState.apiUrl}/api/v1/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier, password }) }); - - if (!response.ok) { - const err = await parseErrorResponse(response); - flashErrorMessage(err.details || err.error || 'Login failed', 3000); - return; - } - - const auth = await response.json(); - gameState.authToken = auth.token; - gameState.userId = auth.userId; - gameState.username = auth.username; - localStorage.setItem('authToken', auth.token); - updateAuthIndicator(true); - hideAuthModal(); } catch (error) { - flashErrorMessage('Connection failed'); - } finally { - submitBtn.disabled = false; + const errorInfo = handleApiError('login', error); + setModalMessage('auth-modal-message', errorInfo.statusMessage, 'error'); + setAuthModalBusy(false); + return; } + + if (!response.ok) { + const err = await parseErrorResponse(response); + setModalMessage('auth-modal-message', err.details || err.error || 'Login failed', 'error'); + setAuthModalBusy(false); + return; + } + + let auth; + try { + auth = await response.json(); + } catch (error) { + console.error('Login: response OK but JSON parse failed:', error); + setModalMessage('auth-modal-message', 'Unexpected response from server', 'error'); + setAuthModalBusy(false); + return; + } + + gameState.authToken = auth.token; + gameState.userId = auth.userId; + gameState.username = auth.username; + localStorage.setItem('authToken', auth.token); + updateAuthIndicator(true); + + setModalMessage('auth-modal-message', `Logged in as ${auth.username}`, 'success'); + setTimeout(hideAuthModal, MODAL_SUCCESS_DISPLAY_MS); } async function handleRegister() { + if (gameState.authBusy) return; + const username = document.getElementById('register-username').value.trim(); const email = document.getElementById('register-email').value.trim(); const password = document.getElementById('register-password').value; if (!username || !password) { - flashErrorMessage('Username and password required'); + setModalMessage('auth-modal-message', 'Username and password required', 'error'); return; } if (password.length < 8) { - flashErrorMessage('Password min 8 chars'); + setModalMessage('auth-modal-message', 'Password min 8 chars', 'error'); return; } if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) { - flashErrorMessage('Password needs a letter and number'); + setModalMessage('auth-modal-message', 'Password needs a letter and number', 'error'); return; } - const submitBtn = document.getElementById('register-submit-btn'); - submitBtn.disabled = true; + setAuthModalBusy(true); + clearModalMessage('auth-modal-message'); + const body = { username, password }; + if (email) body.email = email; + + let response; try { - const body = { username, password }; - if (email) body.email = email; - - const response = await fetch(`${gameState.apiUrl}/api/v1/auth/register`, { + response = await fetch(`${gameState.apiUrl}/api/v1/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - - if (!response.ok) { - const err = await parseErrorResponse(response); - flashErrorMessage(err.details || err.error || 'Registration failed', 3000); - return; - } - - const auth = await response.json(); - gameState.authToken = auth.token; - gameState.userId = auth.userId; - gameState.username = auth.username; - localStorage.setItem('authToken', auth.token); - updateAuthIndicator(true); - hideAuthModal(); } catch (error) { - flashErrorMessage('Connection failed'); - } finally { - submitBtn.disabled = false; + const errorInfo = handleApiError('register', error); + setModalMessage('auth-modal-message', errorInfo.statusMessage, 'error'); + setAuthModalBusy(false); + return; } + + if (!response.ok) { + const err = await parseErrorResponse(response); + setModalMessage('auth-modal-message', err.details || err.error || 'Registration failed', 'error'); + setAuthModalBusy(false); + return; + } + + let auth; + try { + auth = await response.json(); + } catch (error) { + console.error('Register: response OK but JSON parse failed:', error); + setModalMessage('auth-modal-message', 'Unexpected response from server', 'error'); + setAuthModalBusy(false); + return; + } + + gameState.authToken = auth.token; + gameState.userId = auth.userId; + gameState.username = auth.username; + localStorage.setItem('authToken', auth.token); + updateAuthIndicator(true); + + setModalMessage('auth-modal-message', `Account created, welcome ${auth.username}`, 'success'); + setTimeout(hideAuthModal, MODAL_SUCCESS_DISPLAY_MS); } async function handleLogout() { @@ -375,6 +453,19 @@ function updateTurnIndicator(state, turn) { indicator.setAttribute('data-status', tooltipText); } +// Disables/enables every interactive control in the new-game modal at once, and +// tracks whether a create-game request is in flight (or its success message is +// showing). Same rationale as setAuthModalBusy. +function setNewGameModalBusy(busy) { + gameState.newGameBusy = busy; + document.getElementById('start-game-btn').disabled = busy; + document.getElementById('cancel-btn').disabled = busy; + document.getElementById('computer-level').disabled = busy; + document.getElementById('search-time').disabled = busy; + document.getElementById('starting-fen').disabled = busy; + document.querySelectorAll('input[name="player-color"]').forEach(r => r.disabled = busy); +} + function showNewGameModal() { const modal = document.getElementById('modal-overlay'); modal.classList.add('show'); @@ -385,6 +476,8 @@ function hideNewGameModal() { const modal = document.getElementById('modal-overlay'); modal.classList.remove('show'); teardownModalKeyboardNav(); + clearModalMessage('new-game-modal-message'); + setNewGameModalBusy(false); } function setupModalKeyboardNav() { @@ -399,6 +492,14 @@ function handleModalKeydown(e) { const modal = document.getElementById('modal-overlay'); if (!modal.classList.contains('show')) return; + // While a request is in flight, block just Enter (re-submit) and Escape + // (close); the color/level/time shortcuts fall through as no-ops since + // those controls are disabled and there's nothing else bound to those keys. + if (gameState.newGameBusy) { + if (e.key === 'Enter' || e.key === 'Escape') e.preventDefault(); + return; + } + switch(e.key) { case 'Enter': e.preventDefault(); @@ -474,14 +575,16 @@ function copyHistory() { } async function startNewGame() { + if (gameState.newGameBusy) return; + const playerColor = document.querySelector('input[name="player-color"]:checked').value; const computerLevel = parseInt(document.getElementById('computer-level').value); const searchTime = parseInt(document.getElementById('search-time').value); const startingFEN = document.getElementById('starting-fen').value.trim(); - gameState.isPlayerWhite = (playerColor === 'white'); + const willBePlayerWhite = (playerColor === 'white'); - const whiteConfig = gameState.isPlayerWhite ? { type: 1 } : { type: 2, level: computerLevel, searchTime: searchTime }; - const blackConfig = gameState.isPlayerWhite ? { type: 2, level: computerLevel, searchTime: searchTime } : { type: 1 }; + const whiteConfig = willBePlayerWhite ? { type: 1 } : { type: 2, level: computerLevel, searchTime: searchTime }; + const blackConfig = willBePlayerWhite ? { type: 2, level: computerLevel, searchTime: searchTime } : { type: 1 }; const requestBody = { white: whiteConfig, @@ -493,34 +596,51 @@ async function startNewGame() { requestBody.fen = startingFEN; } + setNewGameModalBusy(true); + clearModalMessage('new-game-modal-message'); + + let response; try { - const response = await authFetch(`${gameState.apiUrl}/api/v1/games`, { + response = await authFetch(`${gameState.apiUrl}/api/v1/games`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); - if (!response.ok) { - const errorInfo = handleApiError('create game', null, response); - throw new Error(errorInfo.statusMessage); - } - - const game = await response.json(); - gameState.gameId = game.gameId; - gameState.moveList = []; - hideNewGameModal(); - initializeBoard(); - updateGameDisplay(game); - document.getElementById('undo-btn').disabled = true; - if (!gameState.isPlayerWhite) triggerComputerMove(); - } catch (error) { - if (error.message === 'Failed to fetch') { - handleApiError('create game', error); - } else { - flashErrorMessage(error.message); - } - updateTurnIndicator('', ''); + const errorInfo = handleApiError('create game', error); + setModalMessage('new-game-modal-message', errorInfo.statusMessage, 'error'); + setNewGameModalBusy(false); + return; } + + if (!response.ok) { + const errorInfo = handleApiError('create game', null, response); + setModalMessage('new-game-modal-message', errorInfo.statusMessage, 'error'); + setNewGameModalBusy(false); + return; + } + + let game; + try { + game = await response.json(); + } catch (error) { + console.error('Create game: response OK but JSON parse failed:', error); + setModalMessage('new-game-modal-message', 'Unexpected response from server', 'error'); + setNewGameModalBusy(false); + return; + } + + // isPlayerWhite is only committed to global state now that success is confirmed + gameState.isPlayerWhite = willBePlayerWhite; + gameState.gameId = game.gameId; + gameState.moveList = []; + initializeBoard(); + updateGameDisplay(game); + document.getElementById('undo-btn').disabled = true; + if (!gameState.isPlayerWhite) triggerComputerMove(); + + setModalMessage('new-game-modal-message', `Game started - you play ${willBePlayerWhite ? 'White' : 'Black'}`, 'success'); + setTimeout(hideNewGameModal, MODAL_SUCCESS_DISPLAY_MS); } function initializeBoard() { diff --git a/internal/server/webserver/chess-client-web/index.html b/internal/server/webserver/chess-client-web/index.html index e421675..e775cfc 100644 --- a/internal/server/webserver/chess-client-web/index.html +++ b/internal/server/webserver/chess-client-web/index.html @@ -75,6 +75,8 @@ + +
@@ -117,6 +119,7 @@