duqing2026's picture
feat: enhance UI/UX with glassmorphism, sound effects, and language filter
9fb8186
const codeDisplay = document.getElementById('code-display');
const codeContainer = document.getElementById('code-container');
const hiddenInput = document.getElementById('hidden-input');
const wpmDisplay = document.getElementById('wpm');
const accuracyDisplay = document.getElementById('accuracy');
const progressDisplay = document.getElementById('progress');
const langSelect = document.getElementById('lang-select');
const resultOverlay = document.getElementById('result-overlay');
const finalWpmDisplay = document.getElementById('final-wpm');
const finalAccuracyDisplay = document.getElementById('final-accuracy');
const restartBtn = document.getElementById('restart-btn');
const soundToggleBtn = document.getElementById('sound-toggle');
const soundIcon = document.getElementById('sound-icon');
const focusHint = document.getElementById('focus-hint');
const bestWpmDisplay = document.getElementById('best-wpm');
// Custom Code Elements
const customCodeBtn = document.getElementById('custom-code-btn');
const customModal = document.getElementById('custom-modal');
const customInput = document.getElementById('custom-input');
const customConfirm = document.getElementById('custom-confirm');
const customCancel = document.getElementById('custom-cancel');
// State
let currentCode = "";
let currentIndex = 0;
let startTime = null;
let timer = null;
let mistakes = 0;
let totalTyped = 0;
let isFinished = false;
let soundEnabled = localStorage.getItem('code-typing-sound-enabled') !== 'false'; // Default true
let audioCtx = null;
let bestWpm = parseInt(localStorage.getItem('code-typing-best-wpm') || '0');
// Initialize
async function init() {
updateSoundIcon();
bestWpmDisplay.textContent = bestWpm;
await fetchLanguages();
// Check if we just finished a custom game? No, always fetch new or use custom logic if implemented later.
// For now, default to fetch snippet.
fetchSnippet();
setupEventListeners();
}
async function fetchLanguages() {
try {
const response = await fetch('/api/languages');
const languages = await response.json();
// Preserve selection if possible
const currentSelection = langSelect.value;
langSelect.innerHTML = '';
languages.forEach(lang => {
const option = document.createElement('option');
option.value = lang;
option.textContent = lang;
// Style options (black text on white background for visibility in dropdown)
option.className = "text-slate-900 bg-slate-200";
langSelect.appendChild(option);
});
if (languages.includes(currentSelection)) {
langSelect.value = currentSelection;
}
} catch (e) {
console.error("Failed to fetch languages", e);
}
}
async function fetchSnippet() {
const lang = langSelect.value;
try {
const response = await fetch(`/api/snippet?lang=${lang}`);
const data = await response.json();
setupGame(data.code, data.language);
} catch (e) {
console.error("Failed to fetch snippet", e);
setupGame("print('Hello World')", "Python");
}
}
function setupGame(code, language) {
// Reset state
currentCode = code.replace(/\t/g, " "); // Replace tabs with spaces
currentIndex = 0;
startTime = null;
mistakes = 0;
totalTyped = 0;
isFinished = false;
if (timer) clearInterval(timer);
// Update UI
wpmDisplay.textContent = '0';
accuracyDisplay.textContent = '100%';
progressDisplay.textContent = '0%';
resultOverlay.classList.add('hidden');
// Render Code
renderCode();
// Scroll to top
codeContainer.scrollTop = 0;
hiddenInput.value = '';
hiddenInput.focus();
}
function renderCode() {
codeDisplay.innerHTML = '';
currentCode.split('').forEach((char, index) => {
const span = document.createElement('span');
span.innerText = char;
if (index === 0) span.classList.add('cursor');
// Visual tweak for spaces/newlines
if (char === '\n') {
span.innerHTML = '↵\n';
span.classList.add('text-slate-700', 'opacity-50');
}
codeDisplay.appendChild(span);
});
}
function startTimer() {
if (!startTime) {
startTime = new Date();
timer = setInterval(updateStats, 1000);
initAudio(); // Ensure audio context is ready
}
}
function updateStats() {
if (!startTime) return;
const now = new Date();
const timeDiff = (now - startTime) / 1000 / 60; // in minutes
let wpm = 0;
if (timeDiff > 0) {
// WPM = (Characters / 5) / Minutes
wpm = Math.round((currentIndex / 5) / timeDiff);
wpmDisplay.textContent = wpm;
}
const accuracy = totalTyped === 0 ? 100 : Math.round(((totalTyped - mistakes) / totalTyped) * 100);
accuracyDisplay.textContent = accuracy + '%';
const progress = Math.round((currentIndex / currentCode.length) * 100);
progressDisplay.textContent = progress + '%';
}
function finishGame() {
isFinished = true;
clearInterval(timer);
updateStats();
const finalWpm = parseInt(wpmDisplay.textContent);
finalWpmDisplay.textContent = finalWpm;
finalAccuracyDisplay.textContent = accuracyDisplay.textContent;
// Update Best WPM
if (finalWpm > bestWpm) {
bestWpm = finalWpm;
localStorage.setItem('code-typing-best-wpm', bestWpm);
bestWpmDisplay.textContent = bestWpm;
// Could play a victory sound here
}
resultOverlay.classList.remove('hidden');
restartBtn.focus();
}
// Audio System
function initAudio() {
if (!audioCtx) {
const AudioContext = window.AudioContext || window.webkitAudioContext;
audioCtx = new AudioContext();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
function playClickSound() {
if (!soundEnabled || !audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
// Mechanical switch sound simulation (High pitch short burst)
osc.type = 'triangle';
osc.frequency.setValueAtTime(600, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(300, audioCtx.currentTime + 0.05);
gain.gain.setValueAtTime(0.05, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.05);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.05);
}
function playErrorSound() {
if (!soundEnabled || !audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
// Low thud
osc.type = 'sine';
osc.frequency.setValueAtTime(150, audioCtx.currentTime);
osc.frequency.exponentialRampToValueAtTime(50, audioCtx.currentTime + 0.1);
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.1);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 0.1);
}
function updateSoundIcon() {
soundIcon.textContent = soundEnabled ? '🔊' : '🔇';
soundToggleBtn.classList.toggle('text-blue-400', soundEnabled);
soundToggleBtn.classList.toggle('text-slate-500', !soundEnabled);
}
// Event Listeners
function setupEventListeners() {
// Focus management
document.addEventListener('click', (e) => {
// Don't autofocus if clicking on buttons or modal
if (e.target.closest('button') || e.target.closest('select') || e.target.closest('textarea') || e.target.closest('#custom-modal')) return;
if (!isFinished) {
hiddenInput.focus();
initAudio(); // Initialize audio on first user interaction
}
});
hiddenInput.addEventListener('blur', () => {
if (!isFinished) focusHint.classList.remove('opacity-0');
});
hiddenInput.addEventListener('focus', () => {
focusHint.classList.add('opacity-0');
});
// Language Change
langSelect.addEventListener('change', () => {
fetchSnippet();
hiddenInput.focus();
});
// Sound Toggle
soundToggleBtn.addEventListener('click', () => {
soundEnabled = !soundEnabled;
localStorage.setItem('code-typing-sound-enabled', soundEnabled);
updateSoundIcon();
hiddenInput.focus();
});
// Custom Code Logic
customCodeBtn.addEventListener('click', () => {
customModal.classList.remove('hidden');
customInput.focus();
});
customCancel.addEventListener('click', () => {
customModal.classList.add('hidden');
hiddenInput.focus();
});
customConfirm.addEventListener('click', () => {
const code = customInput.value.trim();
if (code) {
setupGame(code, "Custom");
customModal.classList.add('hidden');
}
});
// Restart
restartBtn.addEventListener('click', () => {
if (langSelect.value === 'Custom') {
// Rerun the custom code
setupGame(currentCode, "Custom");
} else {
fetchSnippet();
}
});
// Typing Logic
window.addEventListener('keydown', handleKeydown);
}
function handleKeydown(e) {
if (customModal.classList.contains('hidden') === false) return; // Don't type if modal is open
if (isFinished) {
if (e.key === 'Enter') {
restartBtn.click();
}
return;
}
// Shortcuts
if (e.key === 'Tab') {
e.preventDefault();
fetchSnippet(); // Quick restart
return;
}
// Prevent default scrolling for Space
if (e.key === ' ' && e.target === document.body) {
e.preventDefault();
}
// Ignore non-character keys
if (e.key.length > 1 && e.key !== 'Enter' && e.key !== 'Backspace') return;
hiddenInput.focus();
startTimer();
const charToType = currentCode[currentIndex];
const spans = codeDisplay.querySelectorAll('span');
if (e.key === 'Backspace') {
if (currentIndex > 0) {
currentIndex--;
const span = spans[currentIndex];
span.className = ''; // Reset classes
// Restore visual tweak for newline
if (span.innerText.includes('↵')) {
span.classList.add('text-slate-700', 'opacity-50');
}
span.classList.add('cursor');
if (currentIndex + 1 < spans.length) {
spans[currentIndex + 1].classList.remove('cursor');
}
}
return;
}
let typedChar = e.key;
if (typedChar === 'Enter') typedChar = '\n';
totalTyped++;
const currentSpan = spans[currentIndex];
if (typedChar === charToType) {
playClickSound();
currentSpan.classList.add('text-emerald-400', 'opacity-100');
currentSpan.classList.remove('text-red-500', 'bg-red-900/50', 'text-slate-700', 'opacity-50', 'cursor');
currentIndex++;
if (currentIndex >= currentCode.length) {
finishGame();
} else {
spans[currentIndex].classList.add('cursor');
// Auto-scroll logic
const cursorRect = spans[currentIndex].getBoundingClientRect();
const containerRect = codeContainer.getBoundingClientRect();
// Keep cursor in the middle third of the screen vertically
const relativeTop = cursorRect.top - containerRect.top;
if (relativeTop > containerRect.height * 0.6) {
codeContainer.scrollTop += 30; // Smooth scroll handled by CSS
}
}
} else {
mistakes++;
playErrorSound();
currentSpan.classList.add('text-red-500', 'bg-red-900/50', 'opacity-100');
}
}
// Start
init();