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 @@
-
+
-
🏅 Student Badges
-
-
+
🏅 Student Achievement System
+
Track student progress and celebrate achievements through badges and rewards.
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📊 Badge Statistics
+
+
+
+
+
⭐
+
-
+
Popular Badge
+
+
@@ -275,10 +332,26 @@
+
+
+
📈 Progress Visualization
+
+
+
+
Word Difficulty Analysis
+
+
+
+
-
+
+
+
@@ -295,6 +368,9 @@
+
+
+
@@ -352,11 +428,14 @@
-
-
-
🏅 Congratulations!
-
You've earned new badges:
-
+
+
+
� Achievement Unlocked! 🎊
+
+
+
+
+
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}
+
+
+
+
+
+
+
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}
+
+
+
+
+
+
+
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}%)
+
+
+
+
+
+
+
+
Words:
+
+ ${session.answers ? session.answers.map(a =>
+ `- ${a.word} - ${a.correct ? '✅ Correct' : '❌ Incorrect'}
`
+ ).join('') : ''}
+
+
+ `;
+
+ 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}`);
});