cloze-reader / src /leaderboardUI.js
milwright
add text input option to initials modal for easier entry
541ff38
/**
* Leaderboard UI
* Modal display and initials entry interface
* Following arcade conventions with vintage aesthetic
*/
export class LeaderboardUI {
constructor(leaderboardService) {
this.service = leaderboardService;
this.modal = null;
this.initialsModal = null;
this.currentSlot = 0;
this.initials = ['A', 'A', 'A'];
this.onInitialsSubmit = null;
this.canSubmitInitials = false; // Prevent accidental immediate submission
}
/**
* Show the leaderboard modal
*/
show() {
// Remove existing modal if any
this.hide();
const data = this.service.getFormattedLeaderboard();
const playerStats = this.service.getPlayerStats();
// Create modal HTML
this.modal = document.createElement('div');
this.modal.className = 'leaderboard-overlay';
this.modal.innerHTML = `
<div class="leaderboard-modal">
<div class="leaderboard-header">
<h2 class="leaderboard-title">High Scores</h2>
<button class="leaderboard-close" aria-label="Close leaderboard">×</button>
</div>
<div class="leaderboard-content">
<div class="leaderboard-list">
${this.generateLeaderboardHTML(data.entries, data.playerInitials)}
</div>
${playerStats.highestLevel > 1 ? `
<div class="leaderboard-player-stats">
<div class="player-best">
Your Best: <span class="highlight">Level ${playerStats.highestLevel}</span>
</div>
<div class="player-stats-details">
<div>Passages: ${playerStats.totalPassagesPassed}/${playerStats.totalPassagesAttempted} (${playerStats.successRate}%)</div>
<div>Longest Streak: ${playerStats.longestStreak}</div>
</div>
</div>
` : ''}
</div>
</div>
`;
document.body.appendChild(this.modal);
// Animate in
requestAnimationFrame(() => {
this.modal.classList.add('visible');
});
// Add event listeners
this.modal.querySelector('.leaderboard-close').addEventListener('click', () => this.hide());
// Prevent clicks inside modal content from closing
this.modal.querySelector('.leaderboard-modal').addEventListener('click', (e) => {
e.stopPropagation();
});
// Close on backdrop click
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) {
this.hide();
}
});
// ESC key to close
this.escHandler = (e) => {
if (e.key === 'Escape') {
this.hide();
}
};
document.addEventListener('keydown', this.escHandler);
}
/**
* Generate HTML for leaderboard entries
*/
generateLeaderboardHTML(entries, playerInitials) {
if (entries.length === 0) {
return `
<div class="leaderboard-empty">
<p>No high scores yet!</p>
<p class="text-sm">Be the first to reach Level 2!</p>
</div>
`;
}
return entries.map(entry => {
const rankClass = this.getRankClass(entry.rank);
const isPlayer = entry.initials === playerInitials;
const playerClass = isPlayer ? 'player-entry' : '';
return `
<div class="leaderboard-entry ${rankClass} ${playerClass}">
<span class="entry-rank">#${entry.rank}</span>
<span class="entry-initials">${entry.initials}</span>
<span class="entry-score">Level ${entry.level}</span>
</div>
`;
}).join('');
}
/**
* Get CSS class for rank-based styling
*/
getRankClass(rank) {
if (rank === 1) return 'rank-gold';
if (rank === 2 || rank === 3) return 'rank-silver';
return 'rank-standard';
}
/**
* Hide the leaderboard modal
*/
hide() {
if (this.modal) {
this.modal.classList.remove('visible');
setTimeout(() => {
if (this.modal && this.modal.parentNode) {
this.modal.parentNode.removeChild(this.modal);
}
this.modal = null;
}, 300);
}
if (this.escHandler) {
document.removeEventListener('keydown', this.escHandler);
this.escHandler = null;
}
}
/**
* Show initials entry screen for new high score
*/
showInitialsEntry(level, round, rank, onSubmit) {
// Store callback
this.onInitialsSubmit = onSubmit;
// Reset initials state
this.currentSlot = 0;
this.canSubmitInitials = false; // Disable submission until user has had time to interact
// Get existing player initials if available
const profile = this.service.getPlayerProfile();
if (profile && profile.initials) {
this.initials = profile.initials.split('');
} else {
this.initials = ['A', 'A', 'A'];
}
// Remove existing modal
this.hideInitialsEntry();
// Create modal HTML
this.initialsModal = document.createElement('div');
this.initialsModal.className = 'leaderboard-overlay initials-overlay';
this.initialsModal.innerHTML = `
<div class="initials-modal">
<div class="initials-header">
<h2 class="initials-title">New High Score</h2>
<div class="initials-achievement">
You reached <span class="highlight">Level ${level}</span>
<br>
<span class="rank-text">${this.getRankText(rank)}</span>
</div>
</div>
<div class="initials-content">
<p class="initials-prompt">Enter or update your initials:</p>
<!-- Text Input Method -->
<div class="text-input-section">
<input type="text" id="initials-text-input" class="initials-text-input" maxlength="3" value="${this.initials.join('')}" placeholder="ABC">
<p class="input-help">Type your 3-letter initials directly</p>
</div>
<!-- Divider -->
<div class="input-divider">
<span>or use arcade controls</span>
</div>
<!-- Arcade Style Method -->
<div class="initials-slots">
${this.initials.map((letter, index) => `
<div class="initial-slot ${index === 0 ? 'active' : ''}" data-slot="${index}">
<div class="slot-letter">${letter}</div>
<div class="slot-arrows">
<button class="arrow-up" data-slot="${index}" data-direction="up" aria-label="Increase letter">▲</button>
<button class="arrow-down" data-slot="${index}" data-direction="down" aria-label="Decrease letter">▼</button>
</div>
</div>
`).join('')}
</div>
<div class="initials-instructions">
<p>Use arrow keys ↑↓ to change letters</p>
<p>Press Tab or ←→ to move between slots</p>
<p>Press Enter to submit</p>
</div>
<button class="initials-submit typewriter-button">
Submit
</button>
</div>
</div>
`;
document.body.appendChild(this.initialsModal);
// Animate in
requestAnimationFrame(() => {
this.initialsModal.classList.add('visible');
});
// Add event listeners with a delay to prevent Enter key from passage submission
// from immediately triggering the modal's submit handler
setTimeout(() => {
this.setupInitialsEventListeners();
// Focus the text input for easier typing
const textInput = this.initialsModal.querySelector('#initials-text-input');
if (textInput) {
textInput.focus();
textInput.select(); // Select all text for easy overwriting
}
// Enable submission after a longer delay to ensure user has time to interact
setTimeout(() => {
this.canSubmitInitials = true;
console.log('🔓 Initials submission enabled - user can now submit');
}, 300);
}, 100);
}
/**
* Get rank description text
*/
getRankText(rank) {
const ordinal = this.getOrdinal(rank);
if (rank === 1) return `${ordinal} place - Top Score`;
if (rank === 2) return `${ordinal} place`;
if (rank === 3) return `${ordinal} place`;
return `${ordinal} place on the leaderboard`;
}
/**
* Get ordinal suffix for rank (1st, 2nd, 3rd, etc.)
*/
getOrdinal(n) {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
/**
* Setup event listeners for initials entry
*/
setupInitialsEventListeners() {
// Text input field
const textInput = this.initialsModal.querySelector('#initials-text-input');
textInput.addEventListener('input', (e) => {
const value = e.target.value.toUpperCase().slice(0, 3);
e.target.value = value;
// Update arcade slots to match text input
this.updateInitialsFromText(value);
});
// Arrow buttons
this.initialsModal.querySelectorAll('.arrow-up, .arrow-down').forEach(button => {
button.addEventListener('click', (e) => {
const slot = parseInt(e.target.dataset.slot);
const direction = e.target.dataset.direction;
this.changeInitialLetter(slot, direction === 'up' ? 1 : -1);
});
});
// Slot clicking to select
this.initialsModal.querySelectorAll('.initial-slot').forEach(slot => {
slot.addEventListener('click', (e) => {
if (!e.target.closest('.arrow-up') && !e.target.closest('.arrow-down')) {
const slotIndex = parseInt(slot.dataset.slot);
this.selectSlot(slotIndex);
}
});
});
// Submit button
this.initialsModal.querySelector('.initials-submit').addEventListener('click', () => {
this.submitInitials();
});
// Keyboard controls
this.initialsKeyHandler = (e) => {
// If focus is on text input, handle differently
if (e.target.id === 'initials-text-input') {
switch(e.key) {
case 'Enter':
e.preventDefault();
this.submitInitials();
break;
case 'Escape':
e.preventDefault();
this.hideInitialsEntry();
break;
}
return;
}
// Arcade controls when not focused on text input
switch(e.key) {
case 'ArrowUp':
e.preventDefault();
this.changeInitialLetter(this.currentSlot, 1);
break;
case 'ArrowDown':
e.preventDefault();
this.changeInitialLetter(this.currentSlot, -1);
break;
case 'ArrowLeft':
e.preventDefault();
this.selectSlot(Math.max(0, this.currentSlot - 1));
break;
case 'ArrowRight':
case 'Tab':
e.preventDefault();
this.selectSlot(Math.min(2, this.currentSlot + 1));
break;
case 'Enter':
e.preventDefault();
this.submitInitials();
break;
case 'Escape':
e.preventDefault();
this.hideInitialsEntry();
break;
}
};
document.addEventListener('keydown', this.initialsKeyHandler);
// Prevent modal close on backdrop click for initials entry
this.initialsModal.addEventListener('click', (e) => {
e.stopPropagation();
});
}
/**
* Change letter in current slot
*/
changeInitialLetter(slot, delta) {
const currentChar = this.initials[slot].charCodeAt(0);
let newChar = currentChar + delta;
// Wrap around A-Z
if (newChar > 90) newChar = 65; // After Z, go to A
if (newChar < 65) newChar = 90; // Before A, go to Z
this.initials[slot] = String.fromCharCode(newChar);
this.updateInitialsDisplay();
this.updateTextFromInitials();
}
/**
* Select a specific slot
*/
selectSlot(slot) {
this.currentSlot = slot;
this.initialsModal.querySelectorAll('.initial-slot').forEach((el, index) => {
el.classList.toggle('active', index === slot);
});
}
/**
* Update arcade slots from text input
*/
updateInitialsFromText(text) {
// Pad with 'A' if less than 3 characters
const paddedText = text.padEnd(3, 'A');
this.initials = paddedText.split('');
this.updateInitialsDisplay();
}
/**
* Update text input from arcade slots
*/
updateTextFromInitials() {
const textInput = this.initialsModal.querySelector('#initials-text-input');
if (textInput) {
textInput.value = this.initials.join('');
}
}
/**
* Update the visual display of initials
*/
updateInitialsDisplay() {
this.initialsModal.querySelectorAll('.initial-slot').forEach((slot, index) => {
slot.querySelector('.slot-letter').textContent = this.initials[index];
});
}
/**
* Submit initials and save to leaderboard
*/
submitInitials() {
// Prevent accidental immediate submission
if (!this.canSubmitInitials) {
console.log('⏸️ Initials submission blocked - too soon after modal opened');
return;
}
const initialsString = this.initials.join('');
// Save to player profile
const profile = this.service.getPlayerProfile();
profile.initials = initialsString;
profile.hasEnteredInitials = true;
this.service.savePlayerProfile(profile);
// Call the callback
if (this.onInitialsSubmit) {
this.onInitialsSubmit(initialsString);
}
// Hide modal
this.hideInitialsEntry();
// Show success message briefly, then show leaderboard
this.showSuccessMessage(() => {
this.show();
});
}
/**
* Hide initials entry modal
*/
hideInitialsEntry() {
if (this.initialsModal) {
this.initialsModal.classList.remove('visible');
setTimeout(() => {
if (this.initialsModal && this.initialsModal.parentNode) {
this.initialsModal.parentNode.removeChild(this.initialsModal);
}
this.initialsModal = null;
}, 300);
}
if (this.initialsKeyHandler) {
document.removeEventListener('keydown', this.initialsKeyHandler);
this.initialsKeyHandler = null;
}
}
/**
* Show success message after submitting initials
*/
showSuccessMessage(onComplete) {
const successDiv = document.createElement('div');
successDiv.className = 'leaderboard-overlay visible';
successDiv.innerHTML = `
<div class="leaderboard-modal success-message">
<div class="success-content">
<h2>Score Saved</h2>
<p>Your initials have been added to the leaderboard</p>
</div>
</div>
`;
document.body.appendChild(successDiv);
setTimeout(() => {
successDiv.classList.remove('visible');
setTimeout(() => {
if (successDiv.parentNode) {
successDiv.parentNode.removeChild(successDiv);
}
if (onComplete) {
onComplete();
}
}, 300);
}, 1500);
}
/**
* Show notification toast for milestone achievement
*/
showMilestoneNotification(level) {
const toast = document.createElement('div');
toast.className = 'milestone-toast';
toast.innerHTML = `
<div class="toast-content">
Milestone Reached: Level ${level}
</div>
`;
document.body.appendChild(toast);
// Animate in
requestAnimationFrame(() => {
toast.classList.add('visible');
});
// Auto-hide after 3 seconds
setTimeout(() => {
toast.classList.remove('visible');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
}, 3000);
}
}