v0.9.5 web client interactivity improvement

This commit is contained in:
2026-07-06 14:07:16 -04:00
parent d7d31283d6
commit a23ed1fb21
5 changed files with 261 additions and 79 deletions
+191 -71
View File
@@ -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() {
@@ -75,6 +75,8 @@
<button class="auth-tab" data-tab="register">Register</button>
</div>
<div id="auth-modal-message" class="modal-message"></div>
<!-- Login Form -->
<div id="login-form" class="auth-form">
<div class="form-group">
@@ -117,6 +119,7 @@
<div id="modal-overlay" class="modal-overlay">
<div class="modal">
<h2>New Game</h2>
<div id="new-game-modal-message" class="modal-message"></div>
<div class="form-group">
<label class="group-label">Your Color</label>
<div class="radio-group">
@@ -146,4 +149,4 @@
<script src="app.js"></script>
</body>
</html>
</html>
@@ -659,6 +659,53 @@ input[type="range"]::-webkit-slider-thumb {
cursor: pointer;
}
/* --- Modal status message (Issue 2) --- */
.modal-message {
display: none;
margin-bottom: 1rem;
padding: 0.6rem 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
text-align: center;
}
.modal-message.show {
display: block;
}
.modal-message.error {
background: rgba(247, 118, 142, 0.12);
color: var(--tokyo-red);
border: 1px solid rgba(247, 118, 142, 0.4);
}
.modal-message.success {
background: rgba(158, 206, 106, 0.12);
color: var(--tokyo-green);
border: 1px solid rgba(158, 206, 106, 0.4);
}
/* --- Disabled state while a modal request is in flight (Issue 1) --- */
.auth-form input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.auth-tab:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.fen-input:disabled,
input[type="range"]:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.radio-group input:disabled + span {
opacity: 0.5;
}
/* Mobile/Responsiveness */
@media (max-width: 978px) {
@@ -861,4 +908,4 @@ input[type="range"]::-webkit-slider-thumb {
width: clamp(360px, 83vw, 440px);
min-width: clamp(360px, 83vw, 440px);
}
}
}