Major Changes: - 🪟 Popup Menu System: Converted slide-down navigation to draggable, minimizable popup window - ❓ Help Bubble: Added contextual help system for admin dashboard (accessible via ? bubble) - 🎨 Universal Theme Engine: All elements now properly match selected theme across all panels - 📱 Enhanced Tablet Support: Comprehensive touch gestures, handwriting recognition, and Apple Pencil optimization - 🖊️ Unified Write Mode: Combined handwriting and keyboard input with large canvas interface Technical Implementation: - Draggable popup with minimize/restore functionality and boundary constraints - Dynamic theme switching using CSS variables throughout all components - Help system with modal overlay and keyboard shortcuts (ESC to close) - Responsive design optimizations for mobile, tablet, and desktop - Comprehensive CSS overrides to eliminate hardcoded colors - JavaScript fixes for canvas stroke colors and input styling UI/UX Improvements: - Clean admin dashboard without instruction clutter - Professional popup interface with smooth animations - Better contrast and readability across all themes (default, playground, chalkboard, galaxy, nature) - Improved accessibility with proper focus management - Enhanced mobile responsiveness with touch-optimized controls Files Modified: - Menu system: tablet-menu.js, tablet-menu.css - Theme engine: styles.css, themes integration - Admin interface: index.html, ui.js - Input systems: game.js, unified-write.js, handwriting.js - Authentication: auth.js with tablet menu cleanup
517 lines
16 KiB
JavaScript
517 lines
16 KiB
JavaScript
/**
|
|
* Gamification features for Spelling Practice App
|
|
* - Leaderboards
|
|
* - Challenges & Achievements
|
|
* - Streaks
|
|
* - Points system
|
|
*/
|
|
|
|
// Global variables for gamification
|
|
let userProgress = null;
|
|
let challenges = null;
|
|
let leaderboards = null;
|
|
let currentUser = null;
|
|
|
|
// Initialize gamification features
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Event listener will be triggered when a user logs in
|
|
document.addEventListener('userLoggedIn', function(e) {
|
|
currentUser = e.detail.username;
|
|
initGamification(currentUser);
|
|
});
|
|
});
|
|
|
|
// Main initialization function for gamification
|
|
async function initGamification(username) {
|
|
try {
|
|
// Fetch all required data
|
|
await Promise.all([
|
|
fetchUserProgress(username),
|
|
fetchChallenges(),
|
|
fetchLeaderboards()
|
|
]);
|
|
|
|
// Update streak (marks user as active today)
|
|
await updateUserStreak(username);
|
|
|
|
// Render all gamification components
|
|
renderStreakIndicator();
|
|
renderUserStats();
|
|
renderChallenges();
|
|
renderLeaderboards();
|
|
|
|
console.log('🎮 Gamification features initialized');
|
|
} catch (error) {
|
|
console.error('Error initializing gamification:', error);
|
|
}
|
|
}
|
|
|
|
// Fetch user progress data
|
|
async function fetchUserProgress(username) {
|
|
try {
|
|
const response = await fetch(`/getUserProgress?username=${encodeURIComponent(username)}`);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch user progress: ${response.status}`);
|
|
}
|
|
userProgress = await response.json();
|
|
return userProgress;
|
|
} catch (error) {
|
|
console.error('Error fetching user progress:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Fetch challenges data
|
|
async function fetchChallenges() {
|
|
try {
|
|
const response = await fetch('/getChallenges');
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch challenges: ${response.status}`);
|
|
}
|
|
challenges = await response.json();
|
|
return challenges;
|
|
} catch (error) {
|
|
console.error('Error fetching challenges:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Fetch leaderboards data
|
|
async function fetchLeaderboards() {
|
|
try {
|
|
const response = await fetch('/getLeaderboards');
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch leaderboards: ${response.status}`);
|
|
}
|
|
leaderboards = await response.json();
|
|
return leaderboards;
|
|
} catch (error) {
|
|
console.error('Error fetching leaderboards:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Update user streak
|
|
async function updateUserStreak(username) {
|
|
try {
|
|
const response = await fetch('/updateStreak', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ username })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to update streak: ${response.status}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Update local data
|
|
if (userProgress) {
|
|
userProgress.streaks = result.streak;
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Error updating streak:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Complete a challenge
|
|
async function completeChallenge(challengeId, challengeType) {
|
|
if (!currentUser) {
|
|
console.error('No user logged in');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/completeChallenge', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
username: currentUser,
|
|
challengeId,
|
|
challengeType
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to complete challenge: ${response.status}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Update local data
|
|
userProgress = result.updatedProgress;
|
|
|
|
// Update UI
|
|
renderUserStats();
|
|
renderChallenges();
|
|
|
|
// Show success message
|
|
showToast('Challenge completed!', 'success');
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Error completing challenge:', error);
|
|
showToast('Failed to complete challenge', 'error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Render streak indicator in student panel
|
|
function renderStreakIndicator() {
|
|
const streakContainer = document.getElementById('streakContainer');
|
|
if (!streakContainer) return;
|
|
|
|
// Clear previous content
|
|
streakContainer.innerHTML = '';
|
|
|
|
const currentStreak = userProgress?.streaks?.current || 0;
|
|
|
|
// Create streak element
|
|
const streakEl = document.createElement('div');
|
|
streakEl.className = 'streak-container';
|
|
|
|
streakEl.innerHTML = `
|
|
<div class="streak-flame">🔥</div>
|
|
<div class="streak-info">
|
|
<div class="streak-days">${currentStreak} Day${currentStreak === 1 ? '' : 's'}</div>
|
|
<div class="streak-subtitle">${currentStreak > 0 ? 'Current streak' : 'Start your streak today!'}</div>
|
|
<div class="streak-progress">
|
|
${Array.from({ length: 7 }, (_, i) =>
|
|
`<div class="streak-day${i < currentStreak ? ' active' : ''}"></div>`
|
|
).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="streak-record">
|
|
<div class="streak-subtitle">Best</div>
|
|
<div class="streak-best">${userProgress?.streaks?.longest || 0}</div>
|
|
</div>
|
|
`;
|
|
|
|
streakContainer.appendChild(streakEl);
|
|
}
|
|
|
|
// Render user stats in student panel
|
|
function renderUserStats() {
|
|
const statsContainer = document.getElementById('userStatsContainer');
|
|
if (!statsContainer) return;
|
|
|
|
// Clear previous content
|
|
statsContainer.innerHTML = '';
|
|
|
|
// Create stats container
|
|
const statsEl = document.createElement('div');
|
|
statsEl.className = 'user-stats';
|
|
|
|
// Add stat cards
|
|
statsEl.innerHTML = `
|
|
<div class="stat-card">
|
|
<div class="stat-value">${userProgress?.stats?.points || 0}</div>
|
|
<div class="stat-label">Points</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">${userProgress?.stats?.accuracy ? userProgress.stats.accuracy.toFixed(1) + '%' : '0%'}</div>
|
|
<div class="stat-label">Accuracy</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">${userProgress?.stats?.totalSessions || 0}</div>
|
|
<div class="stat-label">Sessions</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">${userProgress?.stats?.totalWords || 0}</div>
|
|
<div class="stat-label">Words</div>
|
|
</div>
|
|
`;
|
|
|
|
statsContainer.appendChild(statsEl);
|
|
}
|
|
|
|
// Initialize containers with default content
|
|
function initializeStudentContainers() {
|
|
// Initialize streak container immediately
|
|
const streakContainer = document.getElementById('streakContainer');
|
|
if (streakContainer && !streakContainer.innerHTML.trim()) {
|
|
renderStreakIndicator();
|
|
}
|
|
|
|
// Initialize stats container immediately
|
|
const statsContainer = document.getElementById('userStatsContainer');
|
|
if (statsContainer && !statsContainer.innerHTML.trim()) {
|
|
renderUserStats();
|
|
}
|
|
}
|
|
|
|
// Render challenges in student panel
|
|
function renderChallenges() {
|
|
const challengesContainer = document.getElementById('challengesContainer');
|
|
if (!challengesContainer || !challenges || !userProgress) return;
|
|
|
|
// Clear previous content
|
|
challengesContainer.innerHTML = '';
|
|
|
|
// Create challenges container
|
|
const challengesEl = document.createElement('div');
|
|
challengesEl.className = 'challenges-container';
|
|
|
|
// Daily challenge
|
|
if (challenges.daily && challenges.daily.current) {
|
|
const dailyChallenge = challenges.daily.current;
|
|
const isCompleted = userProgress.challengesCompleted.daily.includes(dailyChallenge.id);
|
|
|
|
challengesEl.appendChild(createChallengeCard(dailyChallenge, 'daily', isCompleted));
|
|
}
|
|
|
|
// Weekly challenge
|
|
if (challenges.weekly && challenges.weekly.current) {
|
|
const weeklyChallenge = challenges.weekly.current;
|
|
const isCompleted = userProgress.challengesCompleted.weekly.includes(weeklyChallenge.id);
|
|
|
|
challengesEl.appendChild(createChallengeCard(weeklyChallenge, 'weekly', isCompleted));
|
|
}
|
|
|
|
// Achievement challenges (show only 3 unearned ones)
|
|
if (challenges.achievements && challenges.achievements.length > 0) {
|
|
const uncompletedAchievements = challenges.achievements
|
|
.filter(achievement => !userProgress.challengesCompleted.achievements.includes(achievement.id))
|
|
.slice(0, 3);
|
|
|
|
uncompletedAchievements.forEach(achievement => {
|
|
challengesEl.appendChild(createChallengeCard(achievement, 'achievement', false));
|
|
});
|
|
}
|
|
|
|
challengesContainer.appendChild(challengesEl);
|
|
}
|
|
|
|
// Create a challenge card element
|
|
function createChallengeCard(challenge, type, isCompleted) {
|
|
const card = document.createElement('div');
|
|
card.className = `challenge-card ${type}`;
|
|
card.dataset.id = challenge.id;
|
|
|
|
// Calculate progress for the challenge
|
|
let progress = 0;
|
|
let progressText = '';
|
|
|
|
switch (challenge.type) {
|
|
case 'accuracy':
|
|
progress = userProgress.stats.accuracy ? Math.min(userProgress.stats.accuracy / challenge.target, 1) : 0;
|
|
progressText = `${userProgress.stats.accuracy ? userProgress.stats.accuracy.toFixed(1) : 0}% / ${challenge.target}%`;
|
|
break;
|
|
case 'words':
|
|
case 'correctWords':
|
|
progress = Math.min(userProgress.stats.correctWords / challenge.target, 1);
|
|
progressText = `${userProgress.stats.correctWords || 0} / ${challenge.target}`;
|
|
break;
|
|
case 'sessions':
|
|
progress = Math.min(userProgress.stats.totalSessions / challenge.target, 1);
|
|
progressText = `${userProgress.stats.totalSessions || 0} / ${challenge.target}`;
|
|
break;
|
|
case 'streak':
|
|
progress = Math.min(userProgress.streaks.current / challenge.target, 1);
|
|
progressText = `${userProgress.streaks.current || 0} / ${challenge.target}`;
|
|
break;
|
|
case 'perfectSessions':
|
|
// Would need to track this separately
|
|
progress = 0;
|
|
progressText = `0 / ${challenge.target}`;
|
|
break;
|
|
}
|
|
|
|
// Format date for challenge time period
|
|
let timeframe = '';
|
|
if (type === 'daily') {
|
|
timeframe = `Today (${new Date(challenge.date).toLocaleDateString()})`;
|
|
} else if (type === 'weekly') {
|
|
timeframe = `${new Date(challenge.startDate).toLocaleDateString()} - ${new Date(challenge.endDate).toLocaleDateString()}`;
|
|
}
|
|
|
|
card.innerHTML = `
|
|
<div class="challenge-header">
|
|
<h3 class="challenge-title">${challenge.title}</h3>
|
|
<span class="challenge-badge ${type}">${
|
|
type === 'daily' ? 'Daily'
|
|
: type === 'weekly' ? 'Weekly'
|
|
: 'Achievement'
|
|
}</span>
|
|
</div>
|
|
<div class="challenge-description">${challenge.description}</div>
|
|
<div class="challenge-progress">
|
|
<div class="progress-bar-container">
|
|
<div class="progress-bar-fill ${type}" style="width: ${Math.round(progress * 100)}%"></div>
|
|
</div>
|
|
<div style="display: flex; justify-content: space-between; font-size: 0.85rem;">
|
|
<span>${progressText}</span>
|
|
<span>${Math.round(progress * 100)}%</span>
|
|
</div>
|
|
</div>
|
|
<div class="challenge-footer">
|
|
<div class="challenge-meta">
|
|
${timeframe ? `<div>${timeframe}</div>` : ''}
|
|
<div class="challenge-reward">
|
|
<span>Reward:</span>
|
|
<span class="challenge-points">+${challenge.reward.points} pts</span>
|
|
${challenge.reward.badge ? `<span class="badge-icon">🏅 ${challenge.reward.badge}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<button
|
|
class="challenge-complete-btn${isCompleted ? ' completed' : progress >= 1 ? '' : ' locked'}"
|
|
${isCompleted ? 'disabled' : ''}
|
|
onclick="${isCompleted ? '' : progress >= 1 ? `completeChallenge('${challenge.id}', '${type === 'achievement' ? 'achievements' : type}')` : ''}"
|
|
>
|
|
${isCompleted ? 'Completed ✓' : progress >= 1 ? 'Claim Reward' : 'In Progress...'}
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
return card;
|
|
}
|
|
|
|
// Render leaderboards in student panel
|
|
function renderLeaderboards() {
|
|
const leaderboardsContainer = document.getElementById('leaderboardsContainer');
|
|
if (!leaderboardsContainer || !leaderboards) return;
|
|
|
|
// Clear previous content
|
|
leaderboardsContainer.innerHTML = '';
|
|
|
|
// Create leaderboard container
|
|
const leaderboardEl = document.createElement('div');
|
|
leaderboardEl.className = 'leaderboard-container';
|
|
|
|
// Create header with tabs
|
|
const header = document.createElement('div');
|
|
header.className = 'leaderboard-header';
|
|
header.innerHTML = `
|
|
<h3>Leaderboards</h3>
|
|
<div class="leaderboard-tabs">
|
|
<div class="leaderboard-tab active" data-category="accuracy">Accuracy</div>
|
|
<div class="leaderboard-tab" data-category="totalWords">Words</div>
|
|
<div class="leaderboard-tab" data-category="streakDays">Streaks</div>
|
|
</div>
|
|
`;
|
|
|
|
leaderboardEl.appendChild(header);
|
|
|
|
// Create leaderboard table container
|
|
const tableContainer = document.createElement('div');
|
|
tableContainer.id = 'leaderboardTableContainer';
|
|
|
|
// Initially show accuracy leaderboard
|
|
tableContainer.appendChild(createLeaderboardTable('accuracy'));
|
|
|
|
leaderboardEl.appendChild(tableContainer);
|
|
leaderboardsContainer.appendChild(leaderboardEl);
|
|
|
|
// Add event listeners for tabs
|
|
const tabs = header.querySelectorAll('.leaderboard-tab');
|
|
tabs.forEach(tab => {
|
|
tab.addEventListener('click', function() {
|
|
// Remove active class from all tabs
|
|
tabs.forEach(t => t.classList.remove('active'));
|
|
|
|
// Add active class to clicked tab
|
|
this.classList.add('active');
|
|
|
|
// Update table
|
|
const category = this.dataset.category;
|
|
tableContainer.innerHTML = '';
|
|
tableContainer.appendChild(createLeaderboardTable(category));
|
|
});
|
|
});
|
|
}
|
|
|
|
// Create a leaderboard table for a specific category
|
|
function createLeaderboardTable(category) {
|
|
const table = document.createElement('table');
|
|
table.className = 'leaderboard-table';
|
|
|
|
// Create table header
|
|
const thead = document.createElement('thead');
|
|
thead.innerHTML = `
|
|
<tr>
|
|
<th class="leaderboard-rank">#</th>
|
|
<th>User</th>
|
|
<th class="leaderboard-value">${
|
|
category === 'accuracy' ? 'Accuracy'
|
|
: category === 'totalWords' ? 'Words'
|
|
: category === 'sessionsCompleted' ? 'Sessions'
|
|
: 'Streak Days'
|
|
}</th>
|
|
</tr>
|
|
`;
|
|
table.appendChild(thead);
|
|
|
|
// Create table body
|
|
const tbody = document.createElement('tbody');
|
|
|
|
// Get leaderboard data
|
|
const data = leaderboards.allTime[category] || [];
|
|
|
|
if (data.length === 0) {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = '<td colspan="3" style="text-align: center;">No data available</td>';
|
|
tbody.appendChild(tr);
|
|
} else {
|
|
data.forEach((entry, index) => {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td class="leaderboard-rank">${index + 1}</td>
|
|
<td>${entry.username}${entry.username === currentUser ? ' (You)' : ''}</td>
|
|
<td class="leaderboard-value">${
|
|
category === 'accuracy' ? entry.value.toFixed(1) + '%'
|
|
: entry.value
|
|
}</td>
|
|
`;
|
|
|
|
// Highlight current user
|
|
if (entry.username === currentUser) {
|
|
tr.style.backgroundColor = 'var(--primary-soft)';
|
|
}
|
|
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
table.appendChild(tbody);
|
|
return table;
|
|
}
|
|
|
|
// Helper function to show toast messages
|
|
function showToast(message, type = 'info') {
|
|
// Check if toast container exists
|
|
let toastContainer = document.querySelector('.toast-container');
|
|
if (!toastContainer) {
|
|
toastContainer = document.createElement('div');
|
|
toastContainer.className = 'toast-container';
|
|
document.body.appendChild(toastContainer);
|
|
}
|
|
|
|
// Create toast
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast toast-${type}`;
|
|
toast.textContent = message;
|
|
|
|
// Add to container
|
|
toastContainer.appendChild(toast);
|
|
|
|
// Show toast with animation
|
|
setTimeout(() => toast.classList.add('show'), 10);
|
|
|
|
// Remove after delay
|
|
setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 3000);
|
|
}
|
|
|
|
// Expose functions to window for use in HTML
|
|
window.completeChallenge = completeChallenge;
|