diff --git a/public/badges.css b/public/badges.css new file mode 100644 index 0000000..08cac3b --- /dev/null +++ b/public/badges.css @@ -0,0 +1,450 @@ +/* Enhanced Badge System Styling */ + +/* Badge Celebration - User Game View */ +.badge-celebration { + text-align: center; + max-width: 800px; + margin: 0 auto; + padding: 2rem; + background-color: var(--bg-primary); + background-image: linear-gradient(135deg, rgba(255,255,255,0.05) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.05) 75%, transparent 75%); + background-size: 10px 10px; + border-radius: var(--radius); + box-shadow: var(--shadow), 0 10px 30px rgba(0,0,0,0.2); + animation: slide-up 0.5s ease-out forwards; +} + +@keyframes slide-up { + from { + transform: translateY(30px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.badge-celebration h2 { + margin-top: 0; + background: linear-gradient(90deg, #ff9a8b 0%, #ff6a88 55%, #ff99ac 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + font-size: 2rem; + margin-bottom: 2rem; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.05); } + 100% { transform: scale(1); } +} + +.badge-actions { + margin-top: 2rem; + display: flex; + justify-content: center; + gap: 1rem; +} + +/* Badge Card - User Game View */ +.badge-showcase-header { + text-align: center; + margin-bottom: 1.5rem; + font-size: 1.5rem; + font-weight: 700; +} + +.badge-count { + display: inline-block; + background: linear-gradient(135deg, #ff9a8b 0%, #ff6a88 55%, #ff99ac 100%); + color: white; + border-radius: 50%; + width: 2rem; + height: 2rem; + line-height: 2rem; + text-align: center; + margin-right: 0.5rem; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); +} + +.badge-container { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1rem; + perspective: 1000px; +} + +.badge-card { + background-color: var(--bg-primary); + border-radius: 12px; + border: 2px solid; + padding: 1rem; + width: 200px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + transition: transform 0.3s ease, box-shadow 0.3s ease; + transform: scale(0.9) rotateY(10deg); + opacity: 0; + cursor: pointer; +} + +.badge-card-animate { + animation: badgeCardEnter 0.5s forwards; +} + +@keyframes badgeCardEnter { + 0% { + opacity: 0; + transform: scale(0.9) rotateY(10deg); + } + 100% { + opacity: 1; + transform: scale(1) rotateY(0); + } +} + +.badge-card:hover { + transform: translateY(-5px) scale(1.05); + box-shadow: 0 8px 15px rgba(0,0,0,0.2); +} + +.badge-icon { + width: 70px; + height: 70px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; +} + +.badge-icon span { + font-size: 2.5rem; + filter: drop-shadow(0 2px 3px rgba(0,0,0,0.2)); +} + +.badge-info h3 { + margin: 0 0 0.5rem 0; + color: var(--text-primary); +} + +.badge-info p { + font-size: 0.9rem; + color: var(--text-secondary); + margin: 0; +} + +/* Admin Panel Badge View */ +.badge-stats { + background-color: var(--bg-secondary); + border-radius: 10px; + padding: 1rem; + margin-bottom: 1.5rem; + box-shadow: var(--shadow); +} + +.badge-summary { + text-align: center; +} + +.badge-summary h3 { + margin-top: 0; + margin-bottom: 1rem; +} + +.badge-total { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; +} + +.badge-total .badge-count { + width: 3rem; + height: 3rem; + line-height: 3rem; + font-size: 1.8rem; +} + +.badge-label { + font-size: 1.2rem; + font-weight: 600; + color: var(--text-primary); +} + +.badge-categories { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; +} + +.badge-category { + background-color: var(--bg-primary); + border-radius: 8px; + padding: 0.5rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + min-width: 100px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.category-name { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.category-count { + font-size: 1.25rem; + font-weight: 700; + color: var(--primary); +} + +.badge-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1rem; +} + +.admin-badge-card { + background-color: var(--bg-primary); + border-radius: 8px; + border: 2px solid; + padding: 1rem; + display: flex; + align-items: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.admin-badge-card:hover { + transform: translateY(-3px); + box-shadow: 0 5px 10px rgba(0,0,0,0.15); +} + +.admin-badge-icon { + width: 50px; + height: 50px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-right: 1rem; + flex-shrink: 0; +} + +.admin-badge-icon span { + font-size: 1.8rem; +} + +.admin-badge-info { + flex-grow: 1; +} + +.admin-badge-info h4 { + margin: 0 0 0.25rem 0; + color: var(--text-primary); +} + +.admin-badge-desc { + font-size: 0.85rem; + color: var(--text-secondary); + margin: 0 0 0.5rem 0; +} + +.admin-badge-date { + font-size: 0.75rem; + color: var(--text-tertiary); +} + +.empty-badges { + text-align: center; + padding: 2rem; + color: var(--text-secondary); +} + +.empty-badge-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.5; +} + +/* Animations for earned badges */ +@keyframes glimmer { + 0% { filter: brightness(1) drop-shadow(0 0 0px rgba(255,255,255,0)); } + 50% { filter: brightness(1.3) drop-shadow(0 0 10px rgba(255,255,255,0.5)); } + 100% { filter: brightness(1) drop-shadow(0 0 0px rgba(255,255,255,0)); } +} + +.badge-icon span { + animation: glimmer 3s infinite; +} + +/* Badge progress indicators */ +.badge-progress { + width: 100%; + height: 6px; + background-color: var(--bg-secondary); + border-radius: 3px; + margin-top: 0.5rem; + overflow: hidden; +} + +.badge-progress-bar { + height: 100%; + border-radius: 3px; + transition: width 0.5s ease; +} + +/* Badge achievement unlocked effect */ +.badge-unlocked { + animation: unlocked 0.7s ease; +} + +@keyframes unlocked { + 0% { transform: scale(1); } + 50% { transform: scale(1.1); } + 75% { transform: scale(0.95); } + 100% { transform: scale(1); } +} + +/* Badge Admin Layout */ +.badge-admin-layout { + display: grid; + grid-template-columns: 1fr 3fr; + gap: 2rem; +} + +.badge-system-intro { + margin-bottom: 2rem; + color: var(--text-secondary); + font-style: italic; + text-align: center; + max-width: 600px; + margin: 0 auto 2rem; +} + +.badge-student-selector { + background-color: var(--bg-secondary); + border-radius: 10px; + padding: 1.5rem; + box-shadow: var(--shadow); +} + +.badge-student-list { + max-height: 400px; + overflow-y: auto; + margin-top: 1rem; +} + +.badge-student-list ul { + list-style: none; + padding: 0; + margin: 0; +} + +.badge-student-list ul li { + margin-bottom: 0.5rem; +} + +.badge-student-list button { + width: 100%; + text-align: left; + background-color: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem 1rem; + transition: all 0.2s ease; +} + +.badge-student-list button:hover { + background-color: var(--bg-hover); + transform: translateX(5px); +} + +.badge-student-list button.active { + background-color: var(--primary); + color: white; + border-color: var(--primary-dark); +} + +.badge-search { + margin-bottom: 1rem; +} + +.badge-user-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; +} + +.badge-filters { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.badge-filters button { + padding: 0.3rem 0.75rem; + font-size: 0.85rem; +} + +.badge-filters button.active { + background-color: var(--primary); + color: white; +} + +.badge-user-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 2rem; +} + +.badge-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +.badge-stat-card { + background-color: var(--bg-secondary); + border-radius: 10px; + padding: 1.5rem; + text-align: center; + box-shadow: var(--shadow); + transition: transform 0.2s ease; +} + +.badge-stat-card:hover { + transform: translateY(-5px); +} + +.stat-icon { + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.stat-value { + font-size: 1.75rem; + font-weight: 700; + color: var(--primary); + margin-bottom: 0.25rem; +} + +.stat-label { + color: var(--text-secondary); + font-size: 0.95rem; +} diff --git a/public/index.html b/public/index.html index 8179d62..eadfb96 100644 --- a/public/index.html +++ b/public/index.html @@ -5,6 +5,9 @@ ✨ Modern Spelling Practice + + + @@ -13,6 +16,10 @@ + + + + @@ -55,15 +62,15 @@
- - - - - - - - - + + + + + + + + +
@@ -177,18 +184,68 @@ - + @@ -295,6 +368,9 @@ +
+ +
@@ -352,11 +428,14 @@ - - diff --git a/public/js/admin-badges.js b/public/js/admin-badges.js new file mode 100644 index 0000000..d661c84 --- /dev/null +++ b/public/js/admin-badges.js @@ -0,0 +1,543 @@ +// Admin Badge Enhancement Functions + +// Print a certificate for the student's badges +function printBadgeCertificate(username) { + fetch('/getBadges') + .then(res => res.json()) + .then(data => { + const userBadgeData = data[username]; + let badges = []; + + // Handle different badge data formats + if (!userBadgeData) { + badges = []; + } else if (Array.isArray(userBadgeData)) { + // Old format + badges = userBadgeData.map(name => ({ + name, + icon: "🏅", + color: "#4a5568" + })); + } else if (userBadgeData.earned) { + // New format + badges = userBadgeData.earned; + } + + if (badges.length === 0) { + alert("No badges to print for this student."); + return; + } + + // Create certificate window + const printWindow = window.open('', '_blank'); + printWindow.document.write(` + + + + Spelling Achievement Certificate - ${username} + + + +
+
+

Spelling Achievement Certificate

+

For Excellence in Vocabulary & Spelling

+
+ +

This certificate recognizes the outstanding achievements in spelling practice by:

+

${username}

+ +

Earned ${badges.length} Badge${badges.length !== 1 ? 's' : ''}

+ +
+ ${badges.map(badge => ` +
+ + ${badge.icon || '🏅'} + ${badge.name} +
+ `).join('')} +
+ +

Keep up the great work and continue building your vocabulary skills!

+ +
+
Teacher Signature
+
School Administrator
+
+ +
Date: ${new Date().toLocaleDateString()}
+
+ + + + + `); + + printWindow.document.close(); + }); +} + +// Show modal for awarding custom badges +function showCustomBadgeModal(username) { + // Create modal element if it doesn't exist + let modal = document.getElementById('customBadgeModal'); + + if (!modal) { + modal = document.createElement('div'); + modal.id = 'customBadgeModal'; + modal.className = 'modal'; + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + // Set up event listeners for the modal + modal.querySelector('.modal-close').addEventListener('click', () => { + modal.classList.remove('show'); + }); + + // Set up icon selector + document.querySelectorAll('.icon-option').forEach(button => { + button.addEventListener('click', function() { + document.querySelectorAll('.icon-option').forEach(b => b.classList.remove('selected')); + this.classList.add('selected'); + document.getElementById('badgeIcon').value = this.getAttribute('data-icon'); + updateBadgePreview(); + }); + }); + + // Set up color selector + document.querySelectorAll('.color-option').forEach(button => { + button.addEventListener('click', function() { + document.querySelectorAll('.color-option').forEach(b => b.classList.remove('selected')); + this.classList.add('selected'); + document.getElementById('badgeColor').value = this.getAttribute('data-color'); + updateBadgePreview(); + }); + }); + + // Set up live preview + document.getElementById('badgeName').addEventListener('input', updateBadgePreview); + document.getElementById('badgeDescription').addEventListener('input', updateBadgePreview); + + // Award badge button + document.getElementById('awardBadgeButton').addEventListener('click', () => { + const name = document.getElementById('badgeName').value.trim(); + if (!name) { + alert('Please enter a badge name.'); + return; + } + + const badgeData = { + id: name.toLowerCase().replace(/\s/g, '_'), + name: name, + description: document.getElementById('badgeDescription').value.trim(), + icon: document.getElementById('badgeIcon').value, + color: document.getElementById('badgeColor').value, + category: document.getElementById('badgeCategory').value + }; + + // Send request to award badge + fetch('/awardBadges', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: username, + badges: [badgeData] + }) + }) + .then(res => res.text()) + .then(() => { + modal.classList.remove('show'); + // Refresh the badge display + showUserBadges(username); + }) + .catch(err => { + console.error('Error awarding badge:', err); + alert('Error awarding badge. Please try again.'); + }); + }); + + // Function to update badge preview + function updateBadgePreview() { + const previewIcon = document.querySelector('#badgePreview .admin-badge-icon span'); + const previewTitle = document.querySelector('#badgePreview .admin-badge-info h4'); + const previewDesc = document.querySelector('#badgePreview .admin-badge-desc'); + const previewIconBg = document.querySelector('#badgePreview .admin-badge-icon'); + const previewCard = document.querySelector('#badgePreview'); + + const icon = document.getElementById('badgeIcon').value; + const color = document.getElementById('badgeColor').value; + const name = document.getElementById('badgeName').value.trim() || 'Badge Title'; + const description = document.getElementById('badgeDescription').value.trim() || 'Badge description will appear here'; + + previewIcon.textContent = icon; + previewTitle.textContent = name; + previewDesc.textContent = description; + previewIconBg.style.backgroundColor = `${color}40`; + previewCard.style.borderColor = color; + } + } + + // Update modal title with username + modal.querySelector('.modal-header h3').textContent = `Award Custom Badge to ${username}`; + + // Show modal + modal.classList.add('show'); +} + +// Additional styling for the modal +const modalStyle = document.createElement('style'); +modalStyle.textContent = ` + .modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); + overflow: auto; + } + + .modal.show { + display: flex; + align-items: center; + justify-content: center; + } + + .modal-content { + background-color: var(--bg-primary); + border-radius: var(--radius); + max-width: 600px; + width: 100%; + box-shadow: 0 10px 25px rgba(0,0,0,0.2); + animation: modalFadeIn 0.3s; + } + + @keyframes modalFadeIn { + from { opacity: 0; transform: translateY(-20px); } + to { opacity: 1; transform: translateY(0); } + } + + .modal-header { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + } + + .modal-header h3 { + margin: 0; + } + + .modal-close { + background: transparent; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-secondary); + } + + .modal-body { + padding: 1.5rem; + max-height: 70vh; + overflow-y: auto; + } + + .modal-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; + gap: 0.75rem; + } + + .form-group { + margin-bottom: 1.25rem; + } + + .form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + } + + .icon-selector, .color-selector { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .icon-option { + width: 40px; + height: 40px; + font-size: 1.5rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + border: 2px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + } + + .icon-option.selected { + border-color: var(--primary); + background: var(--primary-light); + } + + .color-option { + width: 40px; + height: 40px; + border: 2px solid var(--border); + border-radius: 50%; + cursor: pointer; + } + + .color-option.selected { + border-color: var(--text-primary); + transform: scale(1.1); + box-shadow: 0 0 0 2px var(--bg-primary), 0 0 0 4px var(--primary); + } + + .badge-preview { + margin-top: 2rem; + border-top: 1px solid var(--border); + padding-top: 1rem; + } + + .badge-preview h4 { + margin-top: 0; + margin-bottom: 1rem; + color: var(--text-secondary); + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 1px; + } +`; + +document.head.appendChild(modalStyle); diff --git a/public/js/admin.js b/public/js/admin.js index fbc2050..3bb2307 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -515,34 +515,240 @@ async function loadBadgeViewer() { const userList = document.getElementById("badgeUserList"); userList.innerHTML = ""; - + + // Keep track of badge statistics + let totalBadges = 0; + let mostBadgesStudent = { name: '-', count: 0 }; + let badgeCounts = {}; + let badgesThisWeek = 0; + let currentWeekStart = new Date(); + currentWeekStart.setDate(currentWeekStart.getDate() - 7); + + // Process badge data for (const username in badgeData) { + // Create student list items const li = document.createElement("li"); li.innerHTML = ``; userList.appendChild(li); + + // Calculate badge statistics + let userBadgeCount = 0; + + if (Array.isArray(badgeData[username])) { + // Old format + userBadgeCount = badgeData[username].length; + + // Track badge types + badgeData[username].forEach(badge => { + if (!badgeCounts[badge]) badgeCounts[badge] = 0; + badgeCounts[badge]++; + }); + + } else if (badgeData[username] && badgeData[username].earned) { + // New format + userBadgeCount = badgeData[username].earned.length; + + // Track badge types + badgeData[username].earned.forEach(badge => { + // Count total badges + totalBadges++; + + // Count by badge name + const badgeName = badge.name; + if (!badgeCounts[badgeName]) badgeCounts[badgeName] = 0; + badgeCounts[badgeName]++; + + // Count recent badges + if (badge.earnedAt) { + const earnedDate = new Date(badge.earnedAt); + if (earnedDate > currentWeekStart) { + badgesThisWeek++; + } + } + }); + } + + // Track user with most badges + if (userBadgeCount > mostBadgesStudent.count) { + mostBadgesStudent = { name: username, count: userBadgeCount }; + } } + + // Find most common badge + let mostCommonBadge = { name: '-', count: 0 }; + for (const badge in badgeCounts) { + if (badgeCounts[badge] > mostCommonBadge.count) { + mostCommonBadge = { name: badge, count: badgeCounts[badge] }; + } + } + + // Update badge statistics display + document.getElementById('totalBadgesAwarded').textContent = totalBadges; + document.getElementById('mostBadgesStudent').textContent = mostBadgesStudent.name !== '-' ? + `${mostBadgesStudent.name} (${mostBadgesStudent.count})` : '-'; + document.getElementById('mostCommonBadge').textContent = mostCommonBadge.name !== '-' ? + `${mostCommonBadge.name} (${mostCommonBadge.count})` : '-'; + document.getElementById('badgesThisWeek').textContent = badgesThisWeek; + + // Add search functionality + const searchInput = document.getElementById('badgeStudentSearch'); + searchInput.addEventListener('input', function() { + const searchTerm = this.value.toLowerCase(); + document.querySelectorAll('#badgeUserList li').forEach(li => { + const username = li.textContent.toLowerCase(); + if (username.includes(searchTerm)) { + li.style.display = ''; + } else { + li.style.display = 'none'; + } + }); + }); + + // Add filter functionality + document.querySelectorAll('.badge-filters button').forEach(button => { + button.addEventListener('click', function() { + // Update active state + document.querySelectorAll('.badge-filters button').forEach(btn => { + btn.classList.remove('active'); + }); + this.classList.add('active'); + + // Apply filter + const filter = this.getAttribute('data-filter'); + const badgeCards = document.querySelectorAll('.admin-badge-card'); + + badgeCards.forEach(card => { + if (filter === 'all' || card.getAttribute('data-category') === filter) { + card.style.display = ''; + } else { + card.style.display = 'none'; + } + }); + }); + }); + + // Initialize print certificates functionality + document.getElementById('printBadges').addEventListener('click', function() { + const username = document.getElementById('badgeUserTitle').textContent.replace('Badges for ', ''); + printBadgeCertificate(username); + }); + + // Initialize custom badge award functionality + document.getElementById('awardCustomBadge').addEventListener('click', function() { + const username = document.getElementById('badgeUserTitle').textContent.replace('Badges for ', ''); + showCustomBadgeModal(username); + }); } function showUserBadges(username) { fetch('/getBadges') .then(res => res.json()) .then(data => { - const badges = data[username] || []; + const userBadgeData = data[username]; const title = document.getElementById("badgeUserTitle"); const badgeList = document.getElementById("badgeUserBadges"); const details = document.getElementById("badgeDetails"); + let badges = []; + + // Handle different badge data formats + if (!userBadgeData) { + badges = []; + } else if (Array.isArray(userBadgeData)) { + // Old format + badges = userBadgeData.map(name => ({ + name, + icon: "🏅", + color: "#4a5568" + })); + } else if (userBadgeData.earned) { + // New format + badges = userBadgeData.earned; + } title.textContent = `Badges for ${username}`; badgeList.innerHTML = ""; + + // Create badge stats summary + const badgeStats = document.createElement("div"); + badgeStats.className = "badge-stats"; + + // Calculate badge counts by category + const categoryStats = {}; + if (userBadgeData && userBadgeData.counts) { + Object.entries(userBadgeData.counts).forEach(([category, count]) => { + categoryStats[category] = count; + }); + } else { + // Calculate from badges array + badges.forEach(badge => { + const category = badge.category || 'other'; + if (!categoryStats[category]) categoryStats[category] = 0; + categoryStats[category]++; + }); + } + + // Create badge summary + badgeStats.innerHTML = ` +
+

Badge Summary

+
+ ${badges.length} + Total Badges +
+
+ ${Object.entries(categoryStats).map(([category, count]) => ` +
+ ${category.charAt(0).toUpperCase() + category.slice(1)} + ${count} +
+ `).join('')} +
+
+ `; + + badgeList.appendChild(badgeStats); if (!badges.length) { - badgeList.innerHTML = "
  • No badges earned yet.
  • "; + const emptyState = document.createElement("div"); + emptyState.className = "empty-badges"; + emptyState.innerHTML = ` +
    🏆
    +

    No badges earned yet

    +

    Complete spelling activities to earn badges!

    + `; + badgeList.appendChild(emptyState); } else { - badges.forEach(b => { - const li = document.createElement("li"); - li.textContent = `🏅 ${b}`; - badgeList.appendChild(li); + // Create badge grid for displaying badges + const badgeGrid = document.createElement("div"); + badgeGrid.className = "badge-grid"; + + badges.forEach(badge => { + const badgeCard = document.createElement("div"); + badgeCard.className = "admin-badge-card"; + + const badgeIcon = badge.icon || "🏅"; + const badgeColor = badge.color || "#4a5568"; + const badgeName = badge.name; + const badgeDesc = badge.description || ""; + const badgeDate = badge.earnedAt ? new Date(badge.earnedAt).toLocaleDateString() : ""; + + badgeCard.style.borderColor = badgeColor; + + badgeCard.innerHTML = ` +
    + ${badgeIcon} +
    +
    +

    ${badgeName}

    + ${badgeDesc ? `

    ${badgeDesc}

    ` : ''} + ${badgeDate ? `
    Earned: ${badgeDate}
    ` : ''} +
    + `; + + badgeGrid.appendChild(badgeCard); }); + + badgeList.appendChild(badgeGrid); } details.classList.remove("hidden"); @@ -576,12 +782,15 @@ window.loadStudentReport = async function(username) { // Handle case where userResults might be an array or undefined let latestReport = null; + let allUserResults = []; if (Array.isArray(userResults) && userResults.length > 0) { // Get the most recent result latestReport = userResults[userResults.length - 1]; + allUserResults = userResults; } else if (userResults && typeof userResults === 'object') { // Handle single result object latestReport = userResults; + allUserResults = [userResults]; } if (!latestReport) { @@ -621,6 +830,16 @@ window.loadStudentReport = async function(username) { badgeContainer.innerHTML = "
  • No badges earned yet.
  • "; } + // Generate progress chart if function exists + if (typeof window.createScoreChart === 'function') { + createScoreChart(username, allUserResults); + } + + // Generate word difficulty chart if function exists + if (typeof window.createWordDifficultyChart === 'function') { + createWordDifficultyChart(username, allUserResults); + } + document.getElementById("reportContent").classList.remove("hidden"); } catch (error) { console.error("Error loading student report:", error); diff --git a/public/js/charts.js b/public/js/charts.js new file mode 100644 index 0000000..bb494d5 --- /dev/null +++ b/public/js/charts.js @@ -0,0 +1,341 @@ +// Charts and visualization for Spelling Practice App + +// Function to create a simple score history line chart +function createScoreChart(username, results) { + // Clear previous chart if it exists + const chartContainer = document.getElementById('progressChart'); + chartContainer.innerHTML = ''; + + if (!results || !Array.isArray(results) || results.length === 0) { + chartContainer.innerHTML = '

    No data available for visualization

    '; + return; + } + + // Extract last 10 results (or fewer if not available) + const recentResults = results.slice(-10); + + // Calculate dimensions + const width = chartContainer.clientWidth || 500; + const height = 250; + const padding = { top: 20, right: 30, bottom: 30, left: 40 }; + const chartWidth = width - padding.left - padding.right; + const chartHeight = height - padding.top - padding.bottom; + + // Create SVG + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', width); + svg.setAttribute('height', height); + svg.style.overflow = 'visible'; + chartContainer.appendChild(svg); + + // Get data points + const dataPoints = recentResults.map((result, index) => { + const score = result.score || 0; + const total = (result.answers && result.answers.length) || 1; + const percentage = (score / total) * 100; + + return { + index, + score: percentage, + date: new Date(result.timestamp || Date.now()).toLocaleDateString() + }; + }); + + // Scale functions + const xScale = index => (index / (dataPoints.length - 1 || 1)) * chartWidth + padding.left; + const yScale = score => chartHeight - (score / 100) * chartHeight + padding.top; + + // Create path for the line + const pathData = dataPoints.map((point, i) => { + return `${i === 0 ? 'M' : 'L'} ${xScale(i)} ${yScale(point.score)}`; + }).join(' '); + + // Create the path element + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', pathData); + path.setAttribute('stroke', 'var(--primary)'); + path.setAttribute('stroke-width', '3'); + path.setAttribute('fill', 'none'); + svg.appendChild(path); + + // Add area under the curve with gradient + const areaPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + const areaPathData = `${pathData} L ${xScale(dataPoints.length - 1)} ${chartHeight + padding.top} L ${xScale(0)} ${chartHeight + padding.top} Z`; + areaPath.setAttribute('d', areaPathData); + areaPath.setAttribute('fill', 'url(#gradient)'); + areaPath.setAttribute('opacity', '0.3'); + + // Create gradient + const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient'); + gradient.setAttribute('id', 'gradient'); + gradient.setAttribute('x1', '0%'); + gradient.setAttribute('y1', '0%'); + gradient.setAttribute('x2', '0%'); + gradient.setAttribute('y2', '100%'); + + const stop1 = document.createElementNS('http://www.w3.org/2000/svg', 'stop'); + stop1.setAttribute('offset', '0%'); + stop1.setAttribute('stop-color', 'var(--primary)'); + + const stop2 = document.createElementNS('http://www.w3.org/2000/svg', 'stop'); + stop2.setAttribute('offset', '100%'); + stop2.setAttribute('stop-color', 'var(--accent)'); + + gradient.appendChild(stop1); + gradient.appendChild(stop2); + + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + defs.appendChild(gradient); + svg.appendChild(defs); + svg.appendChild(areaPath); + + // Add data points + dataPoints.forEach((point, i) => { + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', xScale(i)); + circle.setAttribute('cy', yScale(point.score)); + circle.setAttribute('r', '5'); + circle.setAttribute('fill', 'var(--primary)'); + circle.setAttribute('stroke', 'white'); + circle.setAttribute('stroke-width', '2'); + + // Add hover tooltip + circle.setAttribute('data-date', point.date); + circle.setAttribute('data-score', `${Math.round(point.score)}%`); + circle.addEventListener('mouseover', showTooltip); + circle.addEventListener('mouseout', hideTooltip); + + svg.appendChild(circle); + }); + + // Add axes + const xAxis = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + xAxis.setAttribute('x1', padding.left); + xAxis.setAttribute('y1', chartHeight + padding.top); + xAxis.setAttribute('x2', chartWidth + padding.left); + xAxis.setAttribute('y2', chartHeight + padding.top); + xAxis.setAttribute('stroke', 'var(--text-secondary)'); + xAxis.setAttribute('stroke-width', '1'); + svg.appendChild(xAxis); + + const yAxis = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + yAxis.setAttribute('x1', padding.left); + yAxis.setAttribute('y1', padding.top); + yAxis.setAttribute('x2', padding.left); + yAxis.setAttribute('y2', chartHeight + padding.top); + yAxis.setAttribute('stroke', 'var(--text-secondary)'); + yAxis.setAttribute('stroke-width', '1'); + svg.appendChild(yAxis); + + // Add labels for x-axis (first, middle, last) + if (dataPoints.length > 0) { + [0, Math.floor((dataPoints.length - 1) / 2), dataPoints.length - 1].forEach(i => { + if (i < dataPoints.length) { + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + text.setAttribute('x', xScale(i)); + text.setAttribute('y', chartHeight + padding.top + 20); + text.setAttribute('text-anchor', 'middle'); + text.setAttribute('font-size', '12px'); + text.setAttribute('fill', 'var(--text-secondary)'); + text.textContent = dataPoints[i].date; + svg.appendChild(text); + } + }); + } + + // Add labels for y-axis (0%, 50%, 100%) + [0, 50, 100].forEach(value => { + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + text.setAttribute('x', padding.left - 10); + text.setAttribute('y', yScale(value) + 5); + text.setAttribute('text-anchor', 'end'); + text.setAttribute('font-size', '12px'); + text.setAttribute('fill', 'var(--text-secondary)'); + text.textContent = `${value}%`; + svg.appendChild(text); + + const tickLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + tickLine.setAttribute('x1', padding.left - 5); + tickLine.setAttribute('y1', yScale(value)); + tickLine.setAttribute('x2', padding.left); + tickLine.setAttribute('y2', yScale(value)); + tickLine.setAttribute('stroke', 'var(--text-secondary)'); + tickLine.setAttribute('stroke-width', '1'); + svg.appendChild(tickLine); + }); + + // Add chart title + const title = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + title.setAttribute('x', width / 2); + title.setAttribute('y', 15); + title.setAttribute('text-anchor', 'middle'); + title.setAttribute('font-size', '14px'); + title.setAttribute('fill', 'var(--text-primary)'); + title.setAttribute('font-weight', 'bold'); + title.textContent = `Progress Over Time`; + svg.appendChild(title); + + // Create tooltip element + const tooltip = document.createElement('div'); + tooltip.id = 'chart-tooltip'; + tooltip.style.position = 'absolute'; + tooltip.style.padding = '8px'; + tooltip.style.background = 'var(--bg-primary)'; + tooltip.style.border = '1px solid var(--border)'; + tooltip.style.borderRadius = 'var(--radius)'; + tooltip.style.boxShadow = 'var(--shadow)'; + tooltip.style.pointerEvents = 'none'; + tooltip.style.opacity = '0'; + tooltip.style.transition = 'opacity 0.2s'; + tooltip.style.zIndex = '1000'; + chartContainer.appendChild(tooltip); + + function showTooltip(event) { + const circle = event.target; + const date = circle.getAttribute('data-date'); + const score = circle.getAttribute('data-score'); + + tooltip.textContent = `${date}: ${score}`; + tooltip.style.left = `${event.clientX + 10}px`; + tooltip.style.top = `${event.clientY - 40}px`; + tooltip.style.opacity = '1'; + + circle.setAttribute('r', '7'); // Enlarge circle on hover + } + + function hideTooltip(event) { + tooltip.style.opacity = '0'; + event.target.setAttribute('r', '5'); // Reset circle size + } +} + +// Create a word difficulty analysis chart +function createWordDifficultyChart(username, results) { + const chartContainer = document.getElementById('wordDifficultyChart'); + chartContainer.innerHTML = ''; + + if (!results || !Array.isArray(results) || results.length === 0) { + chartContainer.innerHTML = '

    No data available for word analysis

    '; + return; + } + + // Collect all words and count correct/incorrect attempts + const wordStats = {}; + + results.forEach(result => { + if (result.answers && Array.isArray(result.answers)) { + result.answers.forEach(answer => { + const word = answer.word; + if (!word) return; + + if (!wordStats[word]) { + wordStats[word] = { correct: 0, incorrect: 0, total: 0 }; + } + + if (answer.correct) { + wordStats[word].correct++; + } else { + wordStats[word].incorrect++; + } + wordStats[word].total++; + }); + } + }); + + // Convert to array and sort by difficulty (highest incorrect percentage first) + const sortedWords = Object.entries(wordStats) + .map(([word, stats]) => ({ + word, + correctRate: (stats.correct / stats.total) * 100, + total: stats.total + })) + .filter(item => item.total >= 2) // Only include words attempted at least twice + .sort((a, b) => a.correctRate - b.correctRate) // Sort by difficulty + .slice(0, 10); // Get top 10 most difficult words + + if (sortedWords.length === 0) { + chartContainer.innerHTML = '

    Not enough data for word analysis yet

    '; + return; + } + + // Create horizontal bar chart + const width = chartContainer.clientWidth || 500; + const barHeight = 25; + const height = sortedWords.length * (barHeight + 15) + 50; + const padding = { top: 20, right: 30, bottom: 20, left: 150 }; + const chartWidth = width - padding.left - padding.right; + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', width); + svg.setAttribute('height', height); + chartContainer.appendChild(svg); + + // Scale function + const xScale = value => (value / 100) * chartWidth; + + // Add bars + sortedWords.forEach((item, i) => { + const y = padding.top + i * (barHeight + 15); + + // Bar background (full width) + const backgroundBar = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + backgroundBar.setAttribute('x', padding.left); + backgroundBar.setAttribute('y', y); + backgroundBar.setAttribute('width', chartWidth); + backgroundBar.setAttribute('height', barHeight); + backgroundBar.setAttribute('fill', 'var(--bg-secondary)'); + backgroundBar.setAttribute('rx', '4'); + svg.appendChild(backgroundBar); + + // Bar for correct percentage + const bar = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bar.setAttribute('x', padding.left); + bar.setAttribute('y', y); + bar.setAttribute('width', xScale(item.correctRate)); + bar.setAttribute('height', barHeight); + bar.setAttribute('fill', getColorByRate(item.correctRate)); + bar.setAttribute('rx', '4'); + svg.appendChild(bar); + + // Word label + const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', padding.left - 10); + label.setAttribute('y', y + barHeight/2 + 5); + label.setAttribute('text-anchor', 'end'); + label.setAttribute('font-size', '14px'); + label.setAttribute('fill', 'var(--text-primary)'); + label.textContent = item.word; + svg.appendChild(label); + + // Percentage label + const percentText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + percentText.setAttribute('x', padding.left + xScale(item.correctRate) + 5); + percentText.setAttribute('y', y + barHeight/2 + 5); + percentText.setAttribute('font-size', '14px'); + percentText.setAttribute('fill', 'var(--text-primary)'); + percentText.textContent = `${Math.round(item.correctRate)}%`; + svg.appendChild(percentText); + }); + + // Add title + const title = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + title.setAttribute('x', width / 2); + title.setAttribute('y', 15); + title.setAttribute('text-anchor', 'middle'); + title.setAttribute('font-size', '14px'); + title.setAttribute('fill', 'var(--text-primary)'); + title.setAttribute('font-weight', 'bold'); + title.textContent = `Word Success Rates`; + svg.appendChild(title); + + // Function to get color based on rate + function getColorByRate(rate) { + if (rate < 40) return 'var(--error)'; + if (rate < 70) return 'var(--warning)'; + return 'var(--success)'; + } +} + +// Export functions +window.createScoreChart = createScoreChart; +window.createWordDifficultyChart = createWordDifficultyChart; diff --git a/public/js/export.js b/public/js/export.js new file mode 100644 index 0000000..02a9597 --- /dev/null +++ b/public/js/export.js @@ -0,0 +1,322 @@ +// Export functions for Spelling Practice App + +// Export report as PDF +window.exportToPDF = function() { + showToast('Preparing PDF export...'); + + const content = document.getElementById('reportContent'); + const reportTitle = document.getElementById('reportTitle').textContent; + + // Create a separate div for printing + const printDiv = document.createElement('div'); + printDiv.style.padding = '20px'; + printDiv.style.backgroundColor = '#ffffff'; + printDiv.style.color = '#000000'; + printDiv.style.fontFamily = 'Arial, sans-serif'; + + // Add title + const title = document.createElement('h2'); + title.textContent = reportTitle; + title.style.textAlign = 'center'; + title.style.marginBottom = '20px'; + printDiv.appendChild(title); + + // Add timestamp + const timestamp = document.createElement('p'); + timestamp.textContent = `Generated on: ${new Date().toLocaleString()}`; + timestamp.style.textAlign = 'center'; + timestamp.style.marginBottom = '30px'; + timestamp.style.fontSize = '14px'; + printDiv.appendChild(timestamp); + + // Add latest score + const score = document.createElement('div'); + score.innerHTML = `Latest Score: ${document.getElementById('reportScore').textContent}`; + score.style.marginBottom = '20px'; + score.style.fontSize = '16px'; + printDiv.appendChild(score); + + // Create two columns for words and badges + const columns = document.createElement('div'); + columns.style.display = 'flex'; + columns.style.gap = '40px'; + columns.style.marginBottom = '30px'; + + // Words attempted + const wordsCol = document.createElement('div'); + wordsCol.style.flex = '1'; + const wordsTitle = document.createElement('h3'); + wordsTitle.textContent = 'Words Attempted'; + wordsTitle.style.marginBottom = '10px'; + wordsCol.appendChild(wordsTitle); + + const wordsList = document.createElement('ul'); + wordsList.style.listStyleType = 'none'; + wordsList.style.padding = '0'; + document.querySelectorAll('#reportWords li').forEach(item => { + const li = document.createElement('li'); + li.textContent = item.textContent; + li.style.marginBottom = '5px'; + wordsList.appendChild(li); + }); + wordsCol.appendChild(wordsList); + + // Badges earned + const badgesCol = document.createElement('div'); + badgesCol.style.flex = '1'; + const badgesTitle = document.createElement('h3'); + badgesTitle.textContent = 'Badges Earned'; + badgesTitle.style.marginBottom = '10px'; + badgesCol.appendChild(badgesTitle); + + const badgesList = document.createElement('ul'); + badgesList.style.listStyleType = 'none'; + badgesList.style.padding = '0'; + document.querySelectorAll('#reportBadges li').forEach(item => { + const li = document.createElement('li'); + li.textContent = item.textContent; + li.style.marginBottom = '5px'; + badgesList.appendChild(li); + }); + badgesCol.appendChild(badgesList); + + columns.appendChild(wordsCol); + columns.appendChild(badgesCol); + printDiv.appendChild(columns); + + // Add notes section + const notes = document.createElement('div'); + notes.style.marginTop = '40px'; + notes.style.borderTop = '1px solid #ddd'; + notes.style.paddingTop = '20px'; + + const notesTitle = document.createElement('h3'); + notesTitle.textContent = 'Teacher Notes'; + notes.appendChild(notesTitle); + + const notesText = document.createElement('textarea'); + notesText.style.width = '100%'; + notesText.style.height = '100px'; + notesText.style.padding = '10px'; + notesText.style.border = '1px solid #ddd'; + notesText.style.borderRadius = '4px'; + notesText.style.resize = 'none'; + notes.appendChild(notesText); + + printDiv.appendChild(notes); + + // Footer + const footer = document.createElement('footer'); + footer.style.marginTop = '50px'; + footer.style.textAlign = 'center'; + footer.style.fontSize = '12px'; + footer.style.color = '#666'; + footer.textContent = 'Generated by Modern Spelling Practice App'; + printDiv.appendChild(footer); + + // Hide the original content + content.style.display = 'none'; + + // Add the print div to the body + document.body.appendChild(printDiv); + + // Print the page + setTimeout(() => { + window.print(); + + // Clean up + document.body.removeChild(printDiv); + content.style.display = 'block'; + + showToast('PDF export complete!'); + }, 500); +}; + +// Export report data as CSV +window.exportToCSV = function() { + const username = document.getElementById('reportTitle').textContent.replace('Report for ', ''); + + fetch('/getResults') + .then(res => res.json()) + .then(results => { + const userResults = results[username]; + + if (!userResults || !Array.isArray(userResults) || userResults.length === 0) { + showToast('No data available to export'); + return; + } + + // Create CSV content + let csvContent = 'Date,Score,Total Words,Percentage,Words Attempted,Correct Words\n'; + + userResults.forEach(result => { + const date = new Date(result.timestamp || Date.now()).toLocaleDateString(); + const score = result.score || 0; + const answers = result.answers || []; + const total = answers.length; + const percentage = total > 0 ? Math.round((score / total) * 100) : 0; + + // Create a list of words and whether they were correct + const wordList = answers.map(a => `${a.word}(${a.correct ? 'correct' : 'incorrect'})`).join('; '); + const correctWords = answers.filter(a => a.correct).map(a => a.word).join('; '); + + csvContent += `${date},${score},${total},${percentage}%,"${wordList}","${correctWords}"\n`; + }); + + // Create and download the CSV file + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.setAttribute('download', `${username}_spelling_report.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + showToast('CSV export complete!'); + }) + .catch(error => { + console.error('Error exporting data:', error); + showToast('Error exporting data'); + }); +}; + +// Export a single session as a nicely formatted HTML report +window.exportSessionReport = function(username, sessionData) { + if (!sessionData) return; + + // Create a popup window for the report + const reportWindow = window.open('', '_blank', 'width=800,height=600'); + reportWindow.document.write(` + + + + + Spelling Session Report - ${username} + + + +
    +

    Spelling Session Report

    +

    Student: ${username}

    +

    Session date: ${new Date(sessionData.timestamp || Date.now()).toLocaleString()}

    +
    + +
    +

    Final Score

    +
    ${sessionData.score || 0}/${(sessionData.answers || []).length}
    +

    ${(sessionData.answers || []).length > 0 + ? `${Math.round(((sessionData.score || 0) / (sessionData.answers || []).length) * 100)}%` + : '0%'} +

    +
    + +

    Words Attempted

    +
    + ${(sessionData.answers || []).map(answer => ` +
    + ${answer.correct ? '✓' : '✗'} + ${answer.word} +
    + `).join('')} +
    + +
    + +
    + + + + + `); + reportWindow.document.close(); + + showToast('Session report generated!'); +}; diff --git a/public/js/game.js b/public/js/game.js index 89ce94f..4f227aa 100644 --- a/public/js/game.js +++ b/public/js/game.js @@ -195,8 +195,85 @@ function showSummary() { // 🏆 Badge evaluation const earnedBadges = []; - if (correct === words.length) earnedBadges.push("Perfect Round"); - if (missed.length === 0) earnedBadges.push("No Misses"); + + // Performance badges + if (correct === words.length) earnedBadges.push({ + id: "perfect_round", + name: "Perfect Round", + description: "Got all words correct in a round", + icon: "🎯", + color: "#ffd700", + category: "achievement" + }); + + if (missed.length === 0) earnedBadges.push({ + id: "no_misses", + name: "No Misses", + description: "Completed a round without any mistakes", + icon: "⭐", + color: "#ff9500", + category: "achievement" + }); + + // Speed badges + const averageTime = totalTime / words.length; + if (averageTime < 5 && words.length >= 5) earnedBadges.push({ + id: "speed_demon", + name: "Speed Demon", + description: "Average time less than 5 seconds per word", + icon: "⚡", + color: "#00b4d8", + category: "speed" + }); + + if (totalTime < 30 && words.length >= 10) earnedBadges.push({ + id: "quick_study", + name: "Quick Study", + description: "Complete 10+ words in under 30 seconds", + icon: "🚀", + color: "#8338ec", + category: "speed" + }); + + // Difficulty badges + const averageLength = words.reduce((sum, word) => sum + word.length, 0) / words.length; + if (averageLength > 8 && correct > words.length * 0.8) earnedBadges.push({ + id: "word_wizard", + name: "Word Wizard", + description: "Master of complex vocabulary", + icon: "🧙‍♂️", + color: "#7209b7", + category: "mastery" + }); + + // Streak tracking (access from localStorage) + let streakCount = parseInt(localStorage.getItem('practice_streak') || '0'); + if (correct / words.length >= 0.7) { // At least 70% correct to count for streak + streakCount++; + localStorage.setItem('practice_streak', streakCount); + } else { + streakCount = 0; + localStorage.setItem('practice_streak', '0'); + } + + // Streak badges + if (streakCount >= 3) earnedBadges.push({ + id: "consistent_learner", + name: "Consistent Learner", + description: "3-day practice streak with 70%+ accuracy", + icon: "🔥", + color: "#ff006e", + category: "consistency" + }); + + if (streakCount >= 7) earnedBadges.push({ + id: "weekly_warrior", + name: "Weekly Warrior", + description: "7-day practice streak with 70%+ accuracy", + icon: "⚔️", + color: "#3a0ca3", + category: "consistency" + }); const user = JSON.parse(localStorage.getItem('loggedInUser')); if (user) { @@ -228,17 +305,47 @@ function showSummary() { }) }); - // 🎨 Display badges + // 🎨 Display badges with improved visuals const badgeSection = document.getElementById("badgeDisplay"); const badgeList = document.getElementById("badgeList"); badgeList.innerHTML = ""; - earnedBadges.forEach(b => { - const li = document.createElement("li"); - li.textContent = `🏅 ${b}`; - badgeList.appendChild(li); + + // Add header for the badge showcase + const badgeHeader = document.createElement("div"); + badgeHeader.className = "badge-showcase-header"; + badgeHeader.innerHTML = `${earnedBadges.length} New Badge${earnedBadges.length > 1 ? 's' : ''} Earned!`; + badgeList.appendChild(badgeHeader); + + // Create badge container + const badgeContainer = document.createElement("div"); + badgeContainer.className = "badge-container"; + + earnedBadges.forEach(badge => { + const badgeCard = document.createElement("div"); + badgeCard.className = "badge-card"; + badgeCard.style.borderColor = badge.color || "#4a5568"; + badgeCard.style.boxShadow = `0 4px 6px rgba(0,0,0,0.1), 0 0 10px ${badge.color}40`; + + badgeCard.innerHTML = ` +
    + ${badge.icon || "🏅"} +
    +
    +

    ${badge.name}

    +

    ${badge.description}

    +
    + `; + + // Add animation class after a small delay for staggered effect + setTimeout(() => { + badgeCard.classList.add("badge-card-animate"); + }, 150 * badgeContainer.children.length); + + badgeContainer.appendChild(badgeCard); }); - + + badgeList.appendChild(badgeContainer); badgeSection.classList.remove("hidden"); } else { document.getElementById("badgeDisplay")?.classList.add("hidden"); diff --git a/public/js/sessions.js b/public/js/sessions.js new file mode 100644 index 0000000..4b9e8ff --- /dev/null +++ b/public/js/sessions.js @@ -0,0 +1,115 @@ +// Enhanced User Sessions handling + +// Function to load user sessions with export functionality +window.loadUserSessions = async function(username) { + if (!username) { + document.getElementById('userSessionList').innerHTML = ''; + return; + } + + try { + const res = await fetch('/getResults'); + const results = await res.json(); + + const userSessions = results[username]; + const sessionList = document.getElementById('userSessionList'); + sessionList.innerHTML = ''; + + if (!userSessions || !Array.isArray(userSessions) || userSessions.length === 0) { + sessionList.innerHTML = '
  • No sessions found.
  • '; + return; + } + + userSessions.forEach((session, index) => { + const date = new Date(session.timestamp || Date.now()).toLocaleString(); + const score = session.score || 0; + const total = (session.answers && session.answers.length) || 0; + const percent = total > 0 ? Math.round((score / total) * 100) : 0; + + const li = document.createElement('li'); + li.style = 'padding: 1rem; border-bottom: 1px solid var(--border);'; + li.innerHTML = ` +
    +
    + ${date} +
    Score: ${score}/${total} (${percent}%)
    +
    +
    + + +
    +
    + + `; + + sessionList.appendChild(li); + }); + + // Store the sessions in a global variable for export functionality + window.currentUserSessions = { + username, + sessions: userSessions + }; + + } catch (error) { + console.error("Error loading sessions:", error); + document.getElementById('userSessionList').innerHTML = + '
  • Error loading sessions.
  • '; + } +}; + +// Toggle session details view +window.toggleSessionDetails = function(sessionIndex) { + const detailsDiv = document.getElementById(`session${sessionIndex}`); + detailsDiv.classList.toggle('hidden'); +}; + +// Export all sessions for current user as CSV +window.exportAllSessionsCSV = function() { + if (!window.currentUserSessions || !window.currentUserSessions.sessions) { + showToast('Please select a student first'); + return; + } + + const { username, sessions } = window.currentUserSessions; + + // Create CSV content + let csvContent = 'Date,Score,Total Words,Percentage,Words Correct,Words Incorrect\n'; + + sessions.forEach(session => { + const date = new Date(session.timestamp || Date.now()).toLocaleDateString(); + const score = session.score || 0; + const total = (session.answers && session.answers.length) || 0; + const percent = total > 0 ? Math.round((score / total) * 100) : 0; + + // Count correct and incorrect words + const correctWords = session.answers ? session.answers.filter(a => a.correct).length : 0; + const incorrectWords = total - correctWords; + + csvContent += `${date},${score},${total},${percent}%,${correctWords},${incorrectWords}\n`; + }); + + // Create and download the CSV file + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.setAttribute('download', `${username}_all_sessions.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + showToast('All sessions exported to CSV'); +}; diff --git a/public/js/themes.js b/public/js/themes.js index 7d08112..d511518 100644 --- a/public/js/themes.js +++ b/public/js/themes.js @@ -26,51 +26,72 @@ function applyTheme(themeName) { case 'playground': document.body.className = 'theme-playground'; document.documentElement.style.setProperty('--primary', '#ff6b6b'); + document.documentElement.style.setProperty('--primary-dark', '#ff5252'); document.documentElement.style.setProperty('--accent', '#4ecdc4'); document.documentElement.style.setProperty('--bg-primary', '#fff5f5'); document.documentElement.style.setProperty('--bg-secondary', '#ffebee'); + document.documentElement.style.setProperty('--text-primary', '#2d3748'); + document.documentElement.style.setProperty('--text-secondary', '#4a5568'); + document.documentElement.style.setProperty('--success', '#10b981'); + document.documentElement.style.setProperty('--error', '#ef4444'); + document.documentElement.style.setProperty('--border', '#fecdd3'); break; case 'chalkboard': document.body.className = 'theme-chalkboard'; document.documentElement.style.setProperty('--primary', '#4ade80'); + document.documentElement.style.setProperty('--primary-dark', '#22c55e'); document.documentElement.style.setProperty('--accent', '#22d3ee'); document.documentElement.style.setProperty('--bg-primary', '#1f2937'); document.documentElement.style.setProperty('--bg-secondary', '#374151'); document.documentElement.style.setProperty('--text-primary', '#f9fafb'); document.documentElement.style.setProperty('--text-secondary', '#d1d5db'); + document.documentElement.style.setProperty('--success', '#10b981'); + document.documentElement.style.setProperty('--error', '#f87171'); document.documentElement.style.setProperty('--border', '#4b5563'); break; case 'galaxy': document.body.className = 'theme-galaxy'; document.documentElement.style.setProperty('--primary', '#8b5cf6'); + document.documentElement.style.setProperty('--primary-dark', '#7c3aed'); document.documentElement.style.setProperty('--accent', '#06b6d4'); document.documentElement.style.setProperty('--bg-primary', '#0f0f23'); document.documentElement.style.setProperty('--bg-secondary', '#1a1a3f'); document.documentElement.style.setProperty('--text-primary', '#e0e7ff'); document.documentElement.style.setProperty('--text-secondary', '#a5b4fc'); + document.documentElement.style.setProperty('--success', '#4ade80'); + document.documentElement.style.setProperty('--error', '#f87171'); document.documentElement.style.setProperty('--border', '#3730a3'); break; case 'nature': document.body.className = 'theme-nature'; document.documentElement.style.setProperty('--primary', '#059669'); + document.documentElement.style.setProperty('--primary-dark', '#047857'); document.documentElement.style.setProperty('--accent', '#84cc16'); document.documentElement.style.setProperty('--bg-primary', '#f0fdf4'); document.documentElement.style.setProperty('--bg-secondary', '#dcfce7'); document.documentElement.style.setProperty('--text-primary', '#14532d'); + document.documentElement.style.setProperty('--text-secondary', '#166534'); + document.documentElement.style.setProperty('--success', '#059669'); + document.documentElement.style.setProperty('--error', '#dc2626'); + document.documentElement.style.setProperty('--border', '#86efac'); break; default: // Modern default theme document.body.className = 'theme-default'; // Reset to original CSS custom properties document.documentElement.style.setProperty('--primary', '#3b82f6'); + document.documentElement.style.setProperty('--primary-dark', '#2563eb'); document.documentElement.style.setProperty('--accent', '#06b6d4'); document.documentElement.style.setProperty('--bg-primary', '#ffffff'); document.documentElement.style.setProperty('--bg-secondary', '#f8fafc'); document.documentElement.style.setProperty('--text-primary', '#1e293b'); document.documentElement.style.setProperty('--text-secondary', '#64748b'); + document.documentElement.style.setProperty('--success', '#10b981'); + document.documentElement.style.setProperty('--warning', '#f59e0b'); + document.documentElement.style.setProperty('--error', '#ef4444'); document.documentElement.style.setProperty('--border', '#e2e8f0'); break; } diff --git a/public/mobile.css b/public/mobile.css new file mode 100644 index 0000000..c773395 --- /dev/null +++ b/public/mobile.css @@ -0,0 +1,119 @@ +/* Mobile optimizations for Spelling Practice App */ + +@media screen and (max-width: 768px) { + /* Global layout */ + body { + padding: 1rem 0.5rem; + } + + .container { + padding: 0 0.5rem; + } + + h1 { + font-size: 2rem; + margin-bottom: 1.5rem; + } + + h2 { + font-size: 1.5rem; + } + + h3 { + font-size: 1.2rem; + } + + /* Grid layouts convert to single column */ + #adminTabs { + overflow-x: auto; + padding: 0.5rem; + gap: 0.25rem; + } + + #adminTabs button { + font-size: 0.875rem; + padding: 0.5rem; + white-space: nowrap; + } + + /* Convert grid to single column */ + [style*="grid-template-columns:"] { + grid-template-columns: 1fr !important; + } + + /* Adjust buttons for mobile */ + button { + padding: 0.5rem 1rem; + font-size: 0.875rem; + margin: 0.25rem 0.125rem; + } + + /* Adjust form controls */ + input, select, textarea { + font-size: 16px; /* Prevents iOS zoom */ + padding: 0.5rem; + margin: 0.25rem 0; + } + + /* Navigation */ + #nav { + flex-direction: column; + gap: 0.5rem; + } + + /* Cards and sections */ + .card { + padding: 1rem; + margin-bottom: 1rem; + } + + /* Admin panel tabs */ + .adminTab { + padding: 0.5rem; + } + + /* Game/typing sections */ + #gameSection, #typingSection, #bibleSection { + padding: 0.5rem; + } + + #wordBox { + font-size: 2rem; + padding: 1rem; + } + + /* Student dashboard */ + #activityOptions { + flex-direction: column; + } + + /* Bible typing */ + #bibleVerseDisplay { + padding: 1rem; + font-size: 1rem; + line-height: 1.4; + min-height: 80px; + } + + #bibleInput { + min-height: 100px; + } + + /* Stats */ + #bibleStats { + grid-template-columns: 1fr !important; + } + + /* Reports */ + #progressChart, #wordDifficultyChart { + overflow-x: auto; + max-width: 100%; + } + + /* Toast notifications */ + .toast { + width: 80%; + left: 10%; + bottom: 10px; + } +} diff --git a/public/styles.css b/public/styles.css index bc30686..04898c1 100644 --- a/public/styles.css +++ b/public/styles.css @@ -400,35 +400,33 @@ button:disabled { flex-wrap: wrap; gap: 0.5rem; margin-bottom: 2rem; - padding: 0.5rem; - background: var(--bg-secondary); + padding: 0.75rem; + background: var(--bg-primary); + border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: var(--shadow); } #adminTabs button { flex: 1; min-width: fit-content; - background: transparent; - color: var(--text-secondary); - border: none; border-radius: calc(var(--radius) - 0.25rem); padding: 0.75rem 1rem; - font-weight: 500; transition: var(--transition); - box-shadow: none; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); cursor: pointer; + margin: 0 2px; + /* Base styles now controlled in tab-contrast.css */ } +/* Hover styles are now controlled in tab-contrast.css */ #adminTabs button:hover { - background: var(--bg-primary); - color: var(--text-primary); - transform: none; + transform: translateY(-2px); } +/* Active state is now controlled in tab-contrast.css */ #adminTabs button.active { - background: var(--primary) !important; - color: white !important; - box-shadow: var(--shadow); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2) !important; } .adminTab { diff --git a/public/tab-contrast.css b/public/tab-contrast.css new file mode 100644 index 0000000..21f8976 --- /dev/null +++ b/public/tab-contrast.css @@ -0,0 +1,40 @@ +/* Additional styling to improve tab contrast */ + +/* Make emojis in tab buttons more visible */ +#adminTabs button { + position: relative; + padding-left: 2rem; + /* Apply consistent gray appearance like btn-secondary */ + background: #f1f5f9 !important; + color: #1e293b !important; + border: 2px solid #e2e8f0 !important; + font-weight: 600 !important; + text-shadow: none !important; +} + +/* Style the emojis in tab buttons */ +#adminTabs button::before { + content: attr(data-emoji); + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + font-size: 1.1rem; + filter: saturate(1.2) brightness(1.1); +} + +/* Hover effect like btn-secondary */ +#adminTabs button:hover { + background: #e2e8f0 !important; + color: #1e293b !important; + border-color: #cbd5e1 !important; +} + +/* Active tab styling - consistent across all themes */ +#adminTabs button.active { + background-color: #64748b !important; + color: #ffffff !important; + border-color: #475569 !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2) !important; + font-weight: 700 !important; +} diff --git a/server.js b/server.js index 6776c48..8347eff 100644 --- a/server.js +++ b/server.js @@ -445,13 +445,49 @@ app.post('/awardBadges', (req, res) => { const badgePath = path.join(DATA_DIR, files.badges); const allBadges = readJsonSafe(badgePath); - if (!allBadges[username]) allBadges[username] = []; + if (!allBadges[username]) allBadges[username] = { earned: [], counts: {} }; + + // Initialize structure if old format + if (Array.isArray(allBadges[username])) { + // Convert from old format to new format + const oldBadges = [...allBadges[username]]; + allBadges[username] = { + earned: oldBadges.map(name => ({ + id: name.toLowerCase().replace(/\s/g, '_'), + name, + icon: "🏅", + earnedAt: new Date().toISOString() + })), + counts: {} + }; + } - badges.forEach(b => { - if (!allBadges[username].includes(b)) { - allBadges[username].push(b); + badges.forEach(badge => { + // Check if badge already exists by ID + const existingBadge = allBadges[username].earned.find(b => { + return (typeof b === 'object' && b.id === badge.id) || + (typeof b === 'string' && b === badge.name); + }); + + if (!existingBadge) { + // Add new badge with timestamp + const badgeToAdd = { + ...badge, + earnedAt: new Date().toISOString() + }; + + allBadges[username].earned.push(badgeToAdd); + + // Track badge counts by category + if (badge.category) { + if (!allBadges[username].counts[badge.category]) { + allBadges[username].counts[badge.category] = 0; + } + allBadges[username].counts[badge.category]++; + } } }); + fs.writeFileSync(badgePath, JSON.stringify(allBadges, null, 2)); res.send(`🎉 Badges updated for ${username}`); });