Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5644f564eb | ||
|
|
68e3365a5e | ||
|
|
d652248170 | ||
|
|
6d2987485d | ||
|
|
968322401c | ||
|
|
f294175a4b |
No files matched your search
@@ -0,0 +1,42 @@
|
|||||||
|
name: Docker Build and Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*.*.*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: get_version
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/${{ github.repository }}:latest
|
||||||
|
ghcr.io/${{ github.repository }}:${{ steps.get_version.outputs.VERSION }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -21,8 +21,8 @@ A comprehensive educational application featuring spelling practice, typing exer
|
|||||||
|
|
||||||
#### Quick Start with Docker Compose
|
#### Quick Start with Docker Compose
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.necloud.us/nmemmert/Spelling.git
|
git clone https://github.com/nmemmert/spelling.git
|
||||||
cd Spelling
|
cd spelling
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ docker run -d \
|
|||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-v ./data:/app/data \
|
-v ./data:/app/data \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
git.necloud.us/nmemmert/spelling:latest
|
ghcr.io/nmemmert/spelling:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Custom Docker Compose
|
#### Custom Docker Compose
|
||||||
@@ -42,7 +42,7 @@ docker run -d \
|
|||||||
version: "3.9"
|
version: "3.9"
|
||||||
services:
|
services:
|
||||||
spelling-app:
|
spelling-app:
|
||||||
image: git.necloud.us/nmemmert/spelling:latest
|
image: ghcr.io/nmemmert/spelling:latest
|
||||||
container_name: spelling-app
|
container_name: spelling-app
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
@@ -64,7 +64,7 @@ services:
|
|||||||
name: spelling-practice-app
|
name: spelling-practice-app
|
||||||
services:
|
services:
|
||||||
spelling-app:
|
spelling-app:
|
||||||
image: git.necloud.us/nmemmert/spelling:latest
|
image: ghcr.io/nmemmert/spelling:latest
|
||||||
container_name: spelling-practice
|
container_name: spelling-practice
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
@@ -119,18 +119,18 @@ x-casaos:
|
|||||||
|
|
||||||
4. **Add this configuration and save:**
|
4. **Add this configuration and save:**
|
||||||
```yaml
|
```yaml
|
||||||
version: "3.9"
|
version: "3.9"
|
||||||
services:
|
services:
|
||||||
spelling-app:
|
spelling-app:
|
||||||
image: git.necloud.us/nmemmert/spelling:latest
|
image: ghcr.io/nmemmert/spelling:latest
|
||||||
container_name: spelling-practice
|
container_name: spelling-practice
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /DATA/AppData/spelling:/app/data
|
- /DATA/AppData/spelling:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Deploy the application:**
|
5. **Deploy the application:**
|
||||||
@@ -176,8 +176,8 @@ netstat -tlnp | grep :3000
|
|||||||
### Option 3: Manual Installation
|
### Option 3: Manual Installation
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
# Clone repository
|
||||||
git clone https://git.necloud.us/nmemmert/Spelling.git
|
git clone https://github.com/nmemmert/spelling.git
|
||||||
cd Spelling
|
cd spelling
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
@@ -309,15 +309,28 @@ For Docker installations, map the data directory:
|
|||||||
|
|
||||||
## 🔄 Updates & Maintenance
|
## 🔄 Updates & Maintenance
|
||||||
|
|
||||||
|
|
||||||
### Updating the Application
|
### Updating the Application
|
||||||
```bash
|
```bash
|
||||||
# Pull latest image
|
# Pull latest image from GitHub Container Registry (GHCR)
|
||||||
docker pull git.necloud.us/nmemmert/spelling:latest
|
docker pull ghcr.io/nmemmert/spelling:latest
|
||||||
|
|
||||||
# Restart container
|
# Restart container
|
||||||
docker-compose down && docker-compose up -d
|
docker-compose down && docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Docker Image (GitHub Container Registry)
|
||||||
|
|
||||||
|
To pull and run the latest Docker image for this app from GitHub Container Registry:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker pull ghcr.io/nmemmert/spelling:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
You must be logged in to GHCR with a GitHub account that has access to the repository. See https://github.com/nmemmert/spelling/packages for the image.
|
||||||
|
|
||||||
### Backup Data
|
### Backup Data
|
||||||
```bash
|
```bash
|
||||||
# Backup user data
|
# Backup user data
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "modern-spelling-practice",
|
"name": "modern-spelling-practice",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"description": "A modern, multi-user spelling practice application with admin controls, customizable themes, and analytics",
|
"description": "A modern, multi-user spelling practice application with admin controls, customizable themes, and analytics",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+101
-22
@@ -5,6 +5,9 @@
|
|||||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||||
<title>✨ Modern Spelling Practice</title>
|
<title>✨ Modern Spelling Practice</title>
|
||||||
<link href="styles.css" rel="stylesheet"/>
|
<link href="styles.css" rel="stylesheet"/>
|
||||||
|
<link href="mobile.css" rel="stylesheet"/>
|
||||||
|
<link href="tab-contrast.css" rel="stylesheet"/>
|
||||||
|
<link href="badges.css" rel="stylesheet"/>
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsSHA/2.4.2/sha.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsSHA/2.4.2/sha.js"></script>
|
||||||
<script src="js/auth.js"></script>
|
<script src="js/auth.js"></script>
|
||||||
@@ -13,6 +16,10 @@
|
|||||||
<script src="js/game.js"></script>
|
<script src="js/game.js"></script>
|
||||||
<script src="js/typing.js"></script>
|
<script src="js/typing.js"></script>
|
||||||
<script src="js/bible.js"></script>
|
<script src="js/bible.js"></script>
|
||||||
|
<script src="js/charts.js"></script>
|
||||||
|
<script src="js/export.js"></script>
|
||||||
|
<script src="js/sessions.js"></script>
|
||||||
|
<script src="js/admin-badges.js"></script>
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -55,15 +62,15 @@
|
|||||||
|
|
||||||
<!-- Admin Tabs -->
|
<!-- Admin Tabs -->
|
||||||
<div id="adminTabs">
|
<div id="adminTabs">
|
||||||
<button class="active" data-tab="tabUsers">👤 Users</button>
|
<button class="active" data-tab="tabUsers" data-emoji="👤">Users</button>
|
||||||
<button data-tab="tabWords">📝 Word Lists</button>
|
<button data-tab="tabWords" data-emoji="📝">Word Lists</button>
|
||||||
<button data-tab="tabAnalytics">📊 Analytics</button>
|
<button data-tab="tabAnalytics" data-emoji="📊">Analytics</button>
|
||||||
<button data-tab="tabResults">📋 Results</button>
|
<button data-tab="tabResults" data-emoji="📋">Results</button>
|
||||||
<button data-tab="tabReports">📘 Reports</button>
|
<button data-tab="tabReports" data-emoji="📘">Reports</button>
|
||||||
<button data-tab="tabSessions">📅 Sessions</button>
|
<button data-tab="tabSessions" data-emoji="📅">Sessions</button>
|
||||||
<button data-tab="tabBadges">🏅 Badges</button>
|
<button data-tab="tabBadges" data-emoji="🏅">Badges</button>
|
||||||
<button data-tab="tabPasswords">🔐 Passwords</button>
|
<button data-tab="tabPasswords" data-emoji="🔐">Passwords</button>
|
||||||
<button data-tab="tabTheme">🎨 Themes</button>
|
<button data-tab="tabTheme" data-emoji="🎨">Themes</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users Tab -->
|
<!-- Users Tab -->
|
||||||
@@ -177,18 +184,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Badges Tab -->
|
<!-- Enhanced Badges Tab -->
|
||||||
<div class="adminTab hidden" id="tabBadges">
|
<div class="adminTab hidden" id="tabBadges">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>🏅 Student Badges</h3>
|
<h3>🏅 Student Achievement System</h3>
|
||||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 2rem;">
|
<p class="badge-system-intro">Track student progress and celebrate achievements through badges and rewards.</p>
|
||||||
<div>
|
|
||||||
|
<div class="badge-admin-layout">
|
||||||
|
<div class="badge-student-selector">
|
||||||
<h4>Select Student</h4>
|
<h4>Select Student</h4>
|
||||||
<ul id="badgeUserList"></ul>
|
<div class="badge-search">
|
||||||
|
<input type="text" id="badgeStudentSearch" placeholder="Search students..." class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="badge-student-list">
|
||||||
|
<ul id="badgeUserList"></ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hidden" id="badgeDetails">
|
<div class="hidden" id="badgeDetails">
|
||||||
<h4 id="badgeUserTitle"></h4>
|
<div class="badge-user-header">
|
||||||
<ul id="badgeUserBadges"></ul>
|
<h3 id="badgeUserTitle"></h3>
|
||||||
|
<div class="badge-filters">
|
||||||
|
<button class="btn-sm btn-secondary active" data-filter="all">All</button>
|
||||||
|
<button class="btn-sm btn-secondary" data-filter="achievement">Achievement</button>
|
||||||
|
<button class="btn-sm btn-secondary" data-filter="speed">Speed</button>
|
||||||
|
<button class="btn-sm btn-secondary" data-filter="mastery">Mastery</button>
|
||||||
|
<button class="btn-sm btn-secondary" data-filter="consistency">Consistency</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="badgeUserBadges" class="badge-user-collection"></div>
|
||||||
|
|
||||||
|
<div class="badge-user-actions">
|
||||||
|
<button id="printBadges" class="btn-secondary">Print Certificate</button>
|
||||||
|
<button id="awardCustomBadge" class="btn-primary">Award Custom Badge</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Badge Statistics -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<h3>📊 Badge Statistics</h3>
|
||||||
|
<div class="badge-stats-grid">
|
||||||
|
<div class="badge-stat-card">
|
||||||
|
<div class="stat-icon">🏆</div>
|
||||||
|
<div class="stat-value" id="totalBadgesAwarded">0</div>
|
||||||
|
<div class="stat-label">Total Badges</div>
|
||||||
|
</div>
|
||||||
|
<div class="badge-stat-card">
|
||||||
|
<div class="stat-icon">👑</div>
|
||||||
|
<div class="stat-value" id="mostBadgesStudent">-</div>
|
||||||
|
<div class="stat-label">Most Badges</div>
|
||||||
|
</div>
|
||||||
|
<div class="badge-stat-card">
|
||||||
|
<div class="stat-icon">⭐</div>
|
||||||
|
<div class="stat-value" id="mostCommonBadge">-</div>
|
||||||
|
<div class="stat-label">Popular Badge</div>
|
||||||
|
</div>
|
||||||
|
<div class="badge-stat-card">
|
||||||
|
<div class="stat-icon">🆕</div>
|
||||||
|
<div class="stat-value" id="badgesThisWeek">0</div>
|
||||||
|
<div class="stat-label">This Week</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,10 +332,26 @@
|
|||||||
<ul id="reportBadges"></ul>
|
<ul id="reportBadges"></ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 2rem;">
|
||||||
|
<h4>📈 Progress Visualization</h4>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr; gap: 2rem; margin-top: 1rem;">
|
||||||
|
<div>
|
||||||
|
<h5>Progress Over Time</h5>
|
||||||
|
<div id="progressChart" style="width: 100%; height: 250px; margin-top: 1rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5>Word Difficulty Analysis</h5>
|
||||||
|
<div id="wordDifficultyChart" style="width: 100%; height: 300px; margin-top: 1rem;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 2rem; display: flex; gap: 1rem;">
|
<div style="margin-top: 2rem; display: flex; gap: 1rem; flex-wrap: wrap;">
|
||||||
<button onclick="window.print()" class="btn-secondary">🖨️ Print Report</button>
|
<button onclick="window.print()" class="btn-secondary">🖨️ Print Report</button>
|
||||||
<button onclick="copyReportToClipboard()" class="btn-primary">📋 Copy to Clipboard</button>
|
<button onclick="copyReportToClipboard()" class="btn-primary">📋 Copy to Clipboard</button>
|
||||||
|
<button onclick="exportToPDF()" class="btn-primary">📄 Export as PDF</button>
|
||||||
|
<button onclick="exportToCSV()" class="btn-secondary">📊 Export Data (CSV)</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,6 +368,9 @@
|
|||||||
<option value="">-- Select a student --</option>
|
<option value="">-- Select a student --</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<button onclick="exportAllSessionsCSV()" class="btn-secondary">📊 Export All Sessions (CSV)</button>
|
||||||
|
</div>
|
||||||
<ul id="userSessionList" style="max-height: 400px; overflow-y: auto;"></ul>
|
<ul id="userSessionList" style="max-height: 400px; overflow-y: auto;"></ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -352,11 +428,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Game Summary -->
|
<!-- Game Summary -->
|
||||||
<div class="hidden" id="summary" class="card"></div>
|
<div class="hidden" id="summary" class="card"></div>
|
||||||
<!-- Badge Display -->
|
<!-- Enhanced Badge Display -->
|
||||||
<div class="hidden" id="badgeDisplay" class="card">
|
<div class="hidden" id="badgeDisplay" class="card badge-celebration">
|
||||||
<h3>🏅 Congratulations!</h3>
|
<h2>� Achievement Unlocked! 🎊</h2>
|
||||||
<p>You've earned new badges:</p>
|
<div id="badgeList" class="badge-showcase"></div>
|
||||||
<ul id="badgeList"></ul>
|
<div class="badge-actions">
|
||||||
|
<button onclick="document.getElementById('badgeDisplay').classList.add('hidden')" class="btn-secondary">Continue</button>
|
||||||
|
<button onclick="window.location.reload()" class="btn-primary">Play Again</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Spelling Achievement Certificate - ${username}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
.certificate {
|
||||||
|
max-width: 850px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 40px;
|
||||||
|
background-color: white;
|
||||||
|
border: 10px solid #4f46e5;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.certificate:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
border: 2px solid #818cf8;
|
||||||
|
border-radius: 5px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.certificate-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
.certificate-title {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #4338ca;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.certificate-subtitle {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.student-name {
|
||||||
|
font-size: 28px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 30px 0;
|
||||||
|
color: #1e40af;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
.achievement-text {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
.badges-showcase {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.badge-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 50px;
|
||||||
|
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.badge-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.signature {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
.signature-line {
|
||||||
|
width: 40%;
|
||||||
|
border-top: 1px solid #64748b;
|
||||||
|
padding-top: 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.date {
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-style: italic;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.badge-count {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #4338ca;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.badge-dot {
|
||||||
|
height: 15px;
|
||||||
|
width: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
.certificate-border {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border: 25px solid transparent;
|
||||||
|
border-image: linear-gradient(45deg, #4f46e5, #818cf8) 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.print-button {
|
||||||
|
display: block;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background-color: #4f46e5;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
.print-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
.certificate {
|
||||||
|
box-shadow: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="certificate">
|
||||||
|
<div class="certificate-header">
|
||||||
|
<h1 class="certificate-title">Spelling Achievement Certificate</h1>
|
||||||
|
<h2 class="certificate-subtitle">For Excellence in Vocabulary & Spelling</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="achievement-text">This certificate recognizes the outstanding achievements in spelling practice by:</p>
|
||||||
|
<h2 class="student-name">${username}</h2>
|
||||||
|
|
||||||
|
<p class="badge-count">Earned ${badges.length} Badge${badges.length !== 1 ? 's' : ''}</p>
|
||||||
|
|
||||||
|
<div class="badges-showcase">
|
||||||
|
${badges.map(badge => `
|
||||||
|
<div class="badge-item">
|
||||||
|
<span class="badge-dot" style="background-color: ${badge.color || '#4a5568'}"></span>
|
||||||
|
<span class="badge-icon">${badge.icon || '🏅'}</span>
|
||||||
|
<span>${badge.name}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="achievement-text">Keep up the great work and continue building your vocabulary skills!</p>
|
||||||
|
|
||||||
|
<div class="signature">
|
||||||
|
<div class="signature-line">Teacher Signature</div>
|
||||||
|
<div class="signature-line">School Administrator</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="date">Date: ${new Date().toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="print-button" onclick="window.print()">Print Certificate</button>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Award Custom Badge</h3>
|
||||||
|
<button class="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="customBadgeForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="badgeName">Badge Name</label>
|
||||||
|
<input type="text" id="badgeName" class="form-control" required placeholder="e.g. Special Achievement">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="badgeDescription">Description</label>
|
||||||
|
<textarea id="badgeDescription" class="form-control" rows="3" placeholder="Describe the achievement"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="badgeIcon">Icon</label>
|
||||||
|
<div class="icon-selector">
|
||||||
|
<button type="button" class="icon-option selected" data-icon="🏅">🏅</button>
|
||||||
|
<button type="button" class="icon-option" data-icon="🎯">🎯</button>
|
||||||
|
<button type="button" class="icon-option" data-icon="⭐">⭐</button>
|
||||||
|
<button type="button" class="icon-option" data-icon="🚀">🚀</button>
|
||||||
|
<button type="button" class="icon-option" data-icon="🏆">🏆</button>
|
||||||
|
<button type="button" class="icon-option" data-icon="🎓">🎓</button>
|
||||||
|
<button type="button" class="icon-option" data-icon="📚">📚</button>
|
||||||
|
<button type="button" class="icon-option" data-icon="🔥">🔥</button>
|
||||||
|
<input type="hidden" id="badgeIcon" value="🏅">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="badgeColor">Color</label>
|
||||||
|
<div class="color-selector">
|
||||||
|
<button type="button" class="color-option selected" data-color="#3b82f6" style="background-color: #3b82f6;"></button>
|
||||||
|
<button type="button" class="color-option" data-color="#10b981" style="background-color: #10b981;"></button>
|
||||||
|
<button type="button" class="color-option" data-color="#ef4444" style="background-color: #ef4444;"></button>
|
||||||
|
<button type="button" class="color-option" data-color="#f59e0b" style="background-color: #f59e0b;"></button>
|
||||||
|
<button type="button" class="color-option" data-color="#8b5cf6" style="background-color: #8b5cf6;"></button>
|
||||||
|
<button type="button" class="color-option" data-color="#ec4899" style="background-color: #ec4899;"></button>
|
||||||
|
<input type="hidden" id="badgeColor" value="#3b82f6">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="badgeCategory">Category</label>
|
||||||
|
<select id="badgeCategory" class="form-control">
|
||||||
|
<option value="achievement">Achievement</option>
|
||||||
|
<option value="mastery">Mastery</option>
|
||||||
|
<option value="speed">Speed</option>
|
||||||
|
<option value="consistency">Consistency</option>
|
||||||
|
<option value="special">Special</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="badge-preview">
|
||||||
|
<h4>Badge Preview</h4>
|
||||||
|
<div id="badgePreview" class="admin-badge-card">
|
||||||
|
<div class="admin-badge-icon" style="background-color: #3b82f640">
|
||||||
|
<span>🏅</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-badge-info">
|
||||||
|
<h4>Badge Title</h4>
|
||||||
|
<p class="admin-badge-desc">Badge description will appear here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn-secondary modal-close">Cancel</button>
|
||||||
|
<button id="awardBadgeButton" class="btn-primary">Award Badge</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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);
|
||||||
+226
-7
@@ -515,34 +515,240 @@ async function loadBadgeViewer() {
|
|||||||
|
|
||||||
const userList = document.getElementById("badgeUserList");
|
const userList = document.getElementById("badgeUserList");
|
||||||
userList.innerHTML = "";
|
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) {
|
for (const username in badgeData) {
|
||||||
|
// Create student list items
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.innerHTML = `<button onclick="showUserBadges('${username}')">${username}</button>`;
|
li.innerHTML = `<button onclick="showUserBadges('${username}')">${username}</button>`;
|
||||||
userList.appendChild(li);
|
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) {
|
function showUserBadges(username) {
|
||||||
fetch('/getBadges')
|
fetch('/getBadges')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const badges = data[username] || [];
|
const userBadgeData = data[username];
|
||||||
const title = document.getElementById("badgeUserTitle");
|
const title = document.getElementById("badgeUserTitle");
|
||||||
const badgeList = document.getElementById("badgeUserBadges");
|
const badgeList = document.getElementById("badgeUserBadges");
|
||||||
const details = document.getElementById("badgeDetails");
|
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}`;
|
title.textContent = `Badges for ${username}`;
|
||||||
badgeList.innerHTML = "";
|
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 = `
|
||||||
|
<div class="badge-summary">
|
||||||
|
<h3>Badge Summary</h3>
|
||||||
|
<div class="badge-total">
|
||||||
|
<span class="badge-count">${badges.length}</span>
|
||||||
|
<span class="badge-label">Total Badges</span>
|
||||||
|
</div>
|
||||||
|
<div class="badge-categories">
|
||||||
|
${Object.entries(categoryStats).map(([category, count]) => `
|
||||||
|
<div class="badge-category">
|
||||||
|
<span class="category-name">${category.charAt(0).toUpperCase() + category.slice(1)}</span>
|
||||||
|
<span class="category-count">${count}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
badgeList.appendChild(badgeStats);
|
||||||
|
|
||||||
if (!badges.length) {
|
if (!badges.length) {
|
||||||
badgeList.innerHTML = "<li><em>No badges earned yet.</em></li>";
|
const emptyState = document.createElement("div");
|
||||||
|
emptyState.className = "empty-badges";
|
||||||
|
emptyState.innerHTML = `
|
||||||
|
<div class="empty-badge-icon">🏆</div>
|
||||||
|
<h3>No badges earned yet</h3>
|
||||||
|
<p>Complete spelling activities to earn badges!</p>
|
||||||
|
`;
|
||||||
|
badgeList.appendChild(emptyState);
|
||||||
} else {
|
} else {
|
||||||
badges.forEach(b => {
|
// Create badge grid for displaying badges
|
||||||
const li = document.createElement("li");
|
const badgeGrid = document.createElement("div");
|
||||||
li.textContent = `🏅 ${b}`;
|
badgeGrid.className = "badge-grid";
|
||||||
badgeList.appendChild(li);
|
|
||||||
|
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 = `
|
||||||
|
<div class="admin-badge-icon" style="background-color: ${badgeColor}40">
|
||||||
|
<span>${badgeIcon}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-badge-info">
|
||||||
|
<h4>${badgeName}</h4>
|
||||||
|
${badgeDesc ? `<p class="admin-badge-desc">${badgeDesc}</p>` : ''}
|
||||||
|
${badgeDate ? `<div class="admin-badge-date">Earned: ${badgeDate}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
badgeGrid.appendChild(badgeCard);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
badgeList.appendChild(badgeGrid);
|
||||||
}
|
}
|
||||||
|
|
||||||
details.classList.remove("hidden");
|
details.classList.remove("hidden");
|
||||||
@@ -576,12 +782,15 @@ window.loadStudentReport = async function(username) {
|
|||||||
|
|
||||||
// Handle case where userResults might be an array or undefined
|
// Handle case where userResults might be an array or undefined
|
||||||
let latestReport = null;
|
let latestReport = null;
|
||||||
|
let allUserResults = [];
|
||||||
if (Array.isArray(userResults) && userResults.length > 0) {
|
if (Array.isArray(userResults) && userResults.length > 0) {
|
||||||
// Get the most recent result
|
// Get the most recent result
|
||||||
latestReport = userResults[userResults.length - 1];
|
latestReport = userResults[userResults.length - 1];
|
||||||
|
allUserResults = userResults;
|
||||||
} else if (userResults && typeof userResults === 'object') {
|
} else if (userResults && typeof userResults === 'object') {
|
||||||
// Handle single result object
|
// Handle single result object
|
||||||
latestReport = userResults;
|
latestReport = userResults;
|
||||||
|
allUserResults = [userResults];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!latestReport) {
|
if (!latestReport) {
|
||||||
@@ -621,6 +830,16 @@ window.loadStudentReport = async function(username) {
|
|||||||
badgeContainer.innerHTML = "<li><em>No badges earned yet.</em></li>";
|
badgeContainer.innerHTML = "<li><em>No badges earned yet.</em></li>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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");
|
document.getElementById("reportContent").classList.remove("hidden");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading student report:", error);
|
console.error("Error loading student report:", error);
|
||||||
|
|||||||
@@ -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 = '<p class="text-center">No data available for visualization</p>';
|
||||||
|
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 = '<p class="text-center">No data available for word analysis</p>';
|
||||||
|
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 = '<p class="text-center">Not enough data for word analysis yet</p>';
|
||||||
|
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;
|
||||||
@@ -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 = `<strong>Latest Score:</strong> ${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(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Spelling Session Report - ${username}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
.date {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.score-box {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.score {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2563eb;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.words-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.word-item {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f8fafc;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.correct {
|
||||||
|
border-left: 4px solid #10b981;
|
||||||
|
}
|
||||||
|
.incorrect {
|
||||||
|
border-left: 4px solid #ef4444;
|
||||||
|
}
|
||||||
|
.word-item span {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 2px solid #eee;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="title">Spelling Session Report</h1>
|
||||||
|
<p class="sub-title">Student: ${username}</p>
|
||||||
|
<p class="date">Session date: ${new Date(sessionData.timestamp || Date.now()).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="score-box">
|
||||||
|
<p>Final Score</p>
|
||||||
|
<div class="score">${sessionData.score || 0}/${(sessionData.answers || []).length}</div>
|
||||||
|
<p>${(sessionData.answers || []).length > 0
|
||||||
|
? `${Math.round(((sessionData.score || 0) / (sessionData.answers || []).length) * 100)}%`
|
||||||
|
: '0%'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Words Attempted</h2>
|
||||||
|
<div class="words-list">
|
||||||
|
${(sessionData.answers || []).map(answer => `
|
||||||
|
<div class="word-item ${answer.correct ? 'correct' : 'incorrect'}">
|
||||||
|
<span>${answer.correct ? '✓' : '✗'}</span>
|
||||||
|
<strong>${answer.word}</strong>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 30px;">
|
||||||
|
<button onclick="window.print();" style="
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
">Print Report</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Generated by Modern Spelling Practice App on ${new Date().toLocaleString()}</p>
|
||||||
|
<p>© ${new Date().getFullYear()} Modern Spelling Practice</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
reportWindow.document.close();
|
||||||
|
|
||||||
|
showToast('Session report generated!');
|
||||||
|
};
|
||||||
+115
-8
@@ -195,8 +195,85 @@ function showSummary() {
|
|||||||
|
|
||||||
// 🏆 Badge evaluation
|
// 🏆 Badge evaluation
|
||||||
const earnedBadges = [];
|
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'));
|
const user = JSON.parse(localStorage.getItem('loggedInUser'));
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -228,17 +305,47 @@ function showSummary() {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🎨 Display badges
|
// 🎨 Display badges with improved visuals
|
||||||
const badgeSection = document.getElementById("badgeDisplay");
|
const badgeSection = document.getElementById("badgeDisplay");
|
||||||
const badgeList = document.getElementById("badgeList");
|
const badgeList = document.getElementById("badgeList");
|
||||||
|
|
||||||
badgeList.innerHTML = "";
|
badgeList.innerHTML = "";
|
||||||
earnedBadges.forEach(b => {
|
|
||||||
const li = document.createElement("li");
|
// Add header for the badge showcase
|
||||||
li.textContent = `🏅 ${b}`;
|
const badgeHeader = document.createElement("div");
|
||||||
badgeList.appendChild(li);
|
badgeHeader.className = "badge-showcase-header";
|
||||||
|
badgeHeader.innerHTML = `<span class="badge-count">${earnedBadges.length}</span> 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 = `
|
||||||
|
<div class="badge-icon" style="background-color: ${badge.color}40">
|
||||||
|
<span>${badge.icon || "🏅"}</span>
|
||||||
|
</div>
|
||||||
|
<div class="badge-info">
|
||||||
|
<h3>${badge.name}</h3>
|
||||||
|
<p>${badge.description}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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");
|
badgeSection.classList.remove("hidden");
|
||||||
} else {
|
} else {
|
||||||
document.getElementById("badgeDisplay")?.classList.add("hidden");
|
document.getElementById("badgeDisplay")?.classList.add("hidden");
|
||||||
|
|||||||
@@ -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 = '<li style="padding: 1rem; text-align: center;">No sessions found.</li>';
|
||||||
|
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 = `
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
|
||||||
|
<div>
|
||||||
|
<strong>${date}</strong>
|
||||||
|
<div>Score: ${score}/${total} (${percent}%)</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<button class="btn-secondary" onclick="exportSessionReport('${username}', ${JSON.stringify(session).replace(/"/g, '"')})">
|
||||||
|
📄 Export
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" onclick="toggleSessionDetails(${index})">
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sessionDetails hidden" id="session${index}">
|
||||||
|
<h4 style="margin-top: 1rem;">Words:</h4>
|
||||||
|
<ul>
|
||||||
|
${session.answers ? session.answers.map(a =>
|
||||||
|
`<li>${a.word} - ${a.correct ? '✅ Correct' : '❌ Incorrect'}</li>`
|
||||||
|
).join('') : ''}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 =
|
||||||
|
'<li style="padding: 1rem; text-align: center; color: var(--error);">Error loading sessions.</li>';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
};
|
||||||
@@ -26,51 +26,72 @@ function applyTheme(themeName) {
|
|||||||
case 'playground':
|
case 'playground':
|
||||||
document.body.className = 'theme-playground';
|
document.body.className = 'theme-playground';
|
||||||
document.documentElement.style.setProperty('--primary', '#ff6b6b');
|
document.documentElement.style.setProperty('--primary', '#ff6b6b');
|
||||||
|
document.documentElement.style.setProperty('--primary-dark', '#ff5252');
|
||||||
document.documentElement.style.setProperty('--accent', '#4ecdc4');
|
document.documentElement.style.setProperty('--accent', '#4ecdc4');
|
||||||
document.documentElement.style.setProperty('--bg-primary', '#fff5f5');
|
document.documentElement.style.setProperty('--bg-primary', '#fff5f5');
|
||||||
document.documentElement.style.setProperty('--bg-secondary', '#ffebee');
|
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;
|
break;
|
||||||
|
|
||||||
case 'chalkboard':
|
case 'chalkboard':
|
||||||
document.body.className = 'theme-chalkboard';
|
document.body.className = 'theme-chalkboard';
|
||||||
document.documentElement.style.setProperty('--primary', '#4ade80');
|
document.documentElement.style.setProperty('--primary', '#4ade80');
|
||||||
|
document.documentElement.style.setProperty('--primary-dark', '#22c55e');
|
||||||
document.documentElement.style.setProperty('--accent', '#22d3ee');
|
document.documentElement.style.setProperty('--accent', '#22d3ee');
|
||||||
document.documentElement.style.setProperty('--bg-primary', '#1f2937');
|
document.documentElement.style.setProperty('--bg-primary', '#1f2937');
|
||||||
document.documentElement.style.setProperty('--bg-secondary', '#374151');
|
document.documentElement.style.setProperty('--bg-secondary', '#374151');
|
||||||
document.documentElement.style.setProperty('--text-primary', '#f9fafb');
|
document.documentElement.style.setProperty('--text-primary', '#f9fafb');
|
||||||
document.documentElement.style.setProperty('--text-secondary', '#d1d5db');
|
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');
|
document.documentElement.style.setProperty('--border', '#4b5563');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'galaxy':
|
case 'galaxy':
|
||||||
document.body.className = 'theme-galaxy';
|
document.body.className = 'theme-galaxy';
|
||||||
document.documentElement.style.setProperty('--primary', '#8b5cf6');
|
document.documentElement.style.setProperty('--primary', '#8b5cf6');
|
||||||
|
document.documentElement.style.setProperty('--primary-dark', '#7c3aed');
|
||||||
document.documentElement.style.setProperty('--accent', '#06b6d4');
|
document.documentElement.style.setProperty('--accent', '#06b6d4');
|
||||||
document.documentElement.style.setProperty('--bg-primary', '#0f0f23');
|
document.documentElement.style.setProperty('--bg-primary', '#0f0f23');
|
||||||
document.documentElement.style.setProperty('--bg-secondary', '#1a1a3f');
|
document.documentElement.style.setProperty('--bg-secondary', '#1a1a3f');
|
||||||
document.documentElement.style.setProperty('--text-primary', '#e0e7ff');
|
document.documentElement.style.setProperty('--text-primary', '#e0e7ff');
|
||||||
document.documentElement.style.setProperty('--text-secondary', '#a5b4fc');
|
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');
|
document.documentElement.style.setProperty('--border', '#3730a3');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'nature':
|
case 'nature':
|
||||||
document.body.className = 'theme-nature';
|
document.body.className = 'theme-nature';
|
||||||
document.documentElement.style.setProperty('--primary', '#059669');
|
document.documentElement.style.setProperty('--primary', '#059669');
|
||||||
|
document.documentElement.style.setProperty('--primary-dark', '#047857');
|
||||||
document.documentElement.style.setProperty('--accent', '#84cc16');
|
document.documentElement.style.setProperty('--accent', '#84cc16');
|
||||||
document.documentElement.style.setProperty('--bg-primary', '#f0fdf4');
|
document.documentElement.style.setProperty('--bg-primary', '#f0fdf4');
|
||||||
document.documentElement.style.setProperty('--bg-secondary', '#dcfce7');
|
document.documentElement.style.setProperty('--bg-secondary', '#dcfce7');
|
||||||
document.documentElement.style.setProperty('--text-primary', '#14532d');
|
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;
|
break;
|
||||||
|
|
||||||
default: // Modern default theme
|
default: // Modern default theme
|
||||||
document.body.className = 'theme-default';
|
document.body.className = 'theme-default';
|
||||||
// Reset to original CSS custom properties
|
// Reset to original CSS custom properties
|
||||||
document.documentElement.style.setProperty('--primary', '#3b82f6');
|
document.documentElement.style.setProperty('--primary', '#3b82f6');
|
||||||
|
document.documentElement.style.setProperty('--primary-dark', '#2563eb');
|
||||||
document.documentElement.style.setProperty('--accent', '#06b6d4');
|
document.documentElement.style.setProperty('--accent', '#06b6d4');
|
||||||
document.documentElement.style.setProperty('--bg-primary', '#ffffff');
|
document.documentElement.style.setProperty('--bg-primary', '#ffffff');
|
||||||
document.documentElement.style.setProperty('--bg-secondary', '#f8fafc');
|
document.documentElement.style.setProperty('--bg-secondary', '#f8fafc');
|
||||||
document.documentElement.style.setProperty('--text-primary', '#1e293b');
|
document.documentElement.style.setProperty('--text-primary', '#1e293b');
|
||||||
document.documentElement.style.setProperty('--text-secondary', '#64748b');
|
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');
|
document.documentElement.style.setProperty('--border', '#e2e8f0');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-13
@@ -400,35 +400,33 @@ button:disabled {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
padding: 0.5rem;
|
padding: 0.75rem;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
#adminTabs button {
|
#adminTabs button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: fit-content;
|
min-width: fit-content;
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: none;
|
|
||||||
border-radius: calc(var(--radius) - 0.25rem);
|
border-radius: calc(var(--radius) - 0.25rem);
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
font-weight: 500;
|
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
box-shadow: none;
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
cursor: pointer;
|
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 {
|
#adminTabs button:hover {
|
||||||
background: var(--bg-primary);
|
transform: translateY(-2px);
|
||||||
color: var(--text-primary);
|
|
||||||
transform: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Active state is now controlled in tab-contrast.css */
|
||||||
#adminTabs button.active {
|
#adminTabs button.active {
|
||||||
background: var(--primary) !important;
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2) !important;
|
||||||
color: white !important;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.adminTab {
|
.adminTab {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -445,13 +445,49 @@ app.post('/awardBadges', (req, res) => {
|
|||||||
|
|
||||||
const badgePath = path.join(DATA_DIR, files.badges);
|
const badgePath = path.join(DATA_DIR, files.badges);
|
||||||
const allBadges = readJsonSafe(badgePath);
|
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 => {
|
badges.forEach(badge => {
|
||||||
if (!allBadges[username].includes(b)) {
|
// Check if badge already exists by ID
|
||||||
allBadges[username].push(b);
|
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));
|
fs.writeFileSync(badgePath, JSON.stringify(allBadges, null, 2));
|
||||||
res.send(`🎉 Badges updated for ${username}`);
|
res.send(`🎉 Badges updated for ${username}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user