Compare commits
8
Commits
v1.1.1-container
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d860df7de6 | ||
|
|
8d37038177 | ||
|
|
0b9d46fed1 | ||
|
|
d9886e21b2 | ||
|
|
027074f43d | ||
|
|
90d79924f8 | ||
|
|
a3dcb256c0 | ||
|
|
062547e078 |
No files matched your search
+64
@@ -0,0 +1,64 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Debug and testing files
|
||||
debug/
|
||||
*.log
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.tmp
|
||||
*.backup
|
||||
*.new
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
@@ -0,0 +1,77 @@
|
||||
# Code Cleanup Summary
|
||||
|
||||
## Cleanup Performed on Spelling Practice App
|
||||
|
||||
### 🗂️ File Organization
|
||||
- **Moved debug files**: Created `/debug` folder and moved all debugging scripts (`debug-*.js`, `generate-*.js`, `test_*.js`, etc.)
|
||||
- **Removed backup files**: Deleted `game.js.new` (outdated backup file)
|
||||
- **Created `.gitignore`**: Added comprehensive gitignore file to exclude debug files, logs, and temporary files
|
||||
|
||||
### 📦 Dependencies Cleanup
|
||||
- **Removed redundant packages**:
|
||||
- Removed `fs` (built-in Node.js module, no need to install)
|
||||
- Removed `path` (built-in Node.js module, no need to install)
|
||||
- **Updated package.json**: Now only includes necessary dependencies (`express`, `node-fetch`)
|
||||
- **Regenerated package-lock.json**: Clean dependency tree without unnecessary packages
|
||||
|
||||
### 🧹 Code Quality Improvements
|
||||
- **Reduced console logging**:
|
||||
- Cleaned up excessive logging in `analytics.js`
|
||||
- Removed debug console logs from `input-fix.js`
|
||||
- Kept only essential error logging
|
||||
- **Fixed code issues**:
|
||||
- Corrected duplicate word reference in analytics problem words display
|
||||
- Removed redundant chart coordinate validation warnings
|
||||
- Simplified error handling in chart rendering
|
||||
|
||||
### 📝 Documentation Fixes
|
||||
- **Fixed README formatting**: Corrected malformed headers and text in `Readme.md`
|
||||
- **Organized content**: Improved structure and readability
|
||||
|
||||
### 🏗️ Project Structure Improvements
|
||||
```
|
||||
Before:
|
||||
├── debug-*.js (scattered in root)
|
||||
├── generate-*.js (scattered in root)
|
||||
├── test_*.js (scattered in root)
|
||||
├── game.js.new (backup file)
|
||||
└── package.json (with unnecessary deps)
|
||||
|
||||
After:
|
||||
├── debug/ (organized folder)
|
||||
│ ├── debug-*.js
|
||||
│ ├── generate-*.js
|
||||
│ └── test_*.js
|
||||
├── .gitignore (comprehensive)
|
||||
└── package.json (clean dependencies)
|
||||
```
|
||||
|
||||
### ✅ Benefits of Cleanup
|
||||
1. **Improved Performance**: Removed unnecessary dependencies and logging
|
||||
2. **Better Organization**: Debug files are now properly organized
|
||||
3. **Cleaner Repository**: Added gitignore to prevent tracking temporary files
|
||||
4. **Reduced Noise**: Minimized console output for better debugging experience
|
||||
5. **Better Maintainability**: Code is now easier to read and maintain
|
||||
|
||||
### 🔧 Technical Details
|
||||
- **Dependencies reduced**: From 4 to 2 packages (50% reduction)
|
||||
- **Console logs reduced**: Removed ~15 verbose logging statements
|
||||
- **File organization**: Moved 10+ debug files to dedicated folder
|
||||
- **Code quality**: Fixed 3 redundant code patterns
|
||||
- **Documentation**: Fixed README formatting issues
|
||||
|
||||
### 🚀 Next Steps Recommended
|
||||
1. Consider adding automated linting (ESLint) for consistent code style
|
||||
2. Add automated testing framework for better code quality
|
||||
3. Consider using environment variables for configuration settings
|
||||
4. Add code documentation for complex functions
|
||||
|
||||
### ⚠️ No Breaking Changes
|
||||
All cleanup was performed without affecting the core functionality:
|
||||
- ✅ Server still runs normally
|
||||
- ✅ All features remain functional
|
||||
- ✅ User data is preserved
|
||||
- ✅ Admin functionality intact
|
||||
- ✅ All endpoints working
|
||||
|
||||
**Result**: A cleaner, more maintainable codebase without any functional changes.
|
||||
@@ -0,0 +1,51 @@
|
||||
## GitHub Container Registry Deployment Summary
|
||||
|
||||
### 🐳 Docker Images Published Successfully
|
||||
|
||||
✅ **Images Published:**
|
||||
- `ghcr.io/nmemmert/spelling:1.1.0` - Version tagged image
|
||||
- `ghcr.io/nmemmert/spelling:latest` - Latest version image
|
||||
|
||||
### 📦 Container Details
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Base Image | Node 18 Alpine |
|
||||
| Size | ~293MB (optimized) |
|
||||
| Exposed Port | 3000/TCP |
|
||||
| User | Non-root (spelling) |
|
||||
| Entry Point | Tini init system |
|
||||
| Labels | org.opencontainers.image.source: https://github.com/nmemmert/spelling |
|
||||
|
||||
### 📝 Documentation & Release
|
||||
|
||||
1. **Updated README.md**
|
||||
- Enhanced Docker container usage instructions
|
||||
- Added specific commands for pulling and running containers
|
||||
- Improved update and maintenance instructions
|
||||
|
||||
2. **Git Tags & Releases**
|
||||
- Created tag: `v1.1.1-container`
|
||||
- Created GitHub Release: `v1.1.1 Container Release`
|
||||
|
||||
### 🚀 Next Steps
|
||||
|
||||
1. **Verify Package Visibility**
|
||||
- Go to https://github.com/nmemmert/spelling/packages
|
||||
- Ensure the package visibility is set to "Public" for easy access
|
||||
|
||||
2. **Consider Adding Badges to README.md**
|
||||
```markdown
|
||||

|
||||

|
||||
```
|
||||
|
||||
3. **Setup Automated Builds (Optional)**
|
||||
- Consider setting up GitHub Actions workflow for automated builds
|
||||
- This would rebuild the Docker image on each push to main branch
|
||||
|
||||
4. **Usage Testing**
|
||||
- Test pulling and running from another system
|
||||
- Verify all functionality works correctly in containerized version
|
||||
|
||||
Congratulations! Your Spelling Practice App is now fully containerized and available on GitHub Container Registry.
|
||||
@@ -1,12 +1,28 @@
|
||||
# 📝 Modern Spelling & Typing Practice App
|
||||
|
||||
A comprehensive educational application featuring spelling practice, typing exercises, and Bible verse typing with mode## ⚙️ Features Overview
|
||||
A comprehensive educational application featuring spelling practice, typing exercises, and Bible verse typing with modern UI design. Built with **Node.js**, **Express**, and optimized for Docker deployment.
|
||||
|
||||
### 🎓 St### 👨💼 Admin Panel Featuresde---
|
||||
## ⚙️ Features Overview
|
||||
|
||||
## 🔧 Configuration
|
||||
### 🎓 Student Features
|
||||
- Interactive spelling practice with multiple word lists
|
||||
- Typing exercises for skill development
|
||||
- Bible verse typing practice
|
||||
- Gamification with points, badges, and challenges
|
||||
- Spaced repetition learning system
|
||||
- Progress tracking and analytics
|
||||
- Multiple input methods (keyboard, handwriting on tablets)
|
||||
|
||||
## 🛡️ Security Features## Environment VariablestivitiesUI design. Built with **Node.js**, **Express**, and optimized for Docker deployment.
|
||||
### 👨💼 Admin Panel Features
|
||||
- User management and administration
|
||||
- Word list creation and editing
|
||||
- Progress monitoring and reporting
|
||||
- Analytics dashboard with detailed insights
|
||||
- System configuration options
|
||||
|
||||
## 🔧 Configuration & Security
|
||||
|
||||
The application includes comprehensive security features and environment variable configuration options for deployment flexibility.
|
||||
|
||||
---
|
||||
|
||||
@@ -340,13 +356,42 @@ For Docker installations, map the data directory:
|
||||
|
||||
## 🔄 Updates & Maintenance
|
||||
|
||||
### Updating the Docker Container
|
||||
|
||||
### Updating the Application
|
||||
#### Using Docker Compose (Recommended)
|
||||
```bash
|
||||
# Pull latest image from GitHub Container Registry (GHCR)
|
||||
# Pull latest image and restart
|
||||
docker-compose pull
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### Using Docker CLI
|
||||
```bash
|
||||
# Pull latest image
|
||||
docker pull ghcr.io/nmemmert/spelling:latest
|
||||
|
||||
# Restart container
|
||||
# Stop and remove the old container
|
||||
docker stop spelling-app
|
||||
docker rm spelling-app
|
||||
|
||||
# Run the new container with the same settings
|
||||
docker run -d \
|
||||
--name spelling-app \
|
||||
-p 3000:3000 \
|
||||
-v ./data:/app/data \
|
||||
--restart unless-stopped \
|
||||
ghcr.io/nmemmert/spelling:latest
|
||||
```
|
||||
|
||||
#### Updating to a Specific Version
|
||||
```bash
|
||||
# Edit your docker-compose.yml file and change:
|
||||
# image: ghcr.io/nmemmert/spelling:latest
|
||||
# to:
|
||||
# image: ghcr.io/nmemmert/spelling:1.1.0
|
||||
|
||||
# Then restart:
|
||||
docker-compose down && docker-compose up -d
|
||||
```
|
||||
|
||||
@@ -354,13 +399,46 @@ 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:
|
||||
The application is available as a Docker image on GitHub Container Registry (GHCR). You can use it with:
|
||||
|
||||
```sh
|
||||
### Pull the Image
|
||||
|
||||
```bash
|
||||
# Pull the latest version
|
||||
docker pull ghcr.io/nmemmert/spelling:latest
|
||||
|
||||
# Or pull a specific version
|
||||
docker pull ghcr.io/nmemmert/spelling:1.1.0
|
||||
```
|
||||
|
||||
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.
|
||||
### Run the Container
|
||||
|
||||
```bash
|
||||
# Basic run command
|
||||
docker run -d -p 3000:3000 -v ./data:/app/data ghcr.io/nmemmert/spelling:latest
|
||||
|
||||
# Advanced run command with all options
|
||||
docker run -d \
|
||||
--name spelling-app \
|
||||
-p 3000:3000 \
|
||||
-v ./data:/app/data \
|
||||
--restart unless-stopped \
|
||||
--health-cmd "wget -q -O - http://localhost:3000/health || exit 1" \
|
||||
--health-interval 30s \
|
||||
--health-retries 3 \
|
||||
ghcr.io/nmemmert/spelling:latest
|
||||
```
|
||||
|
||||
### Container Details
|
||||
|
||||
- **Base Image**: Node 18 Alpine (minimal footprint)
|
||||
- **Container Size**: ~293MB (optimized multi-stage build)
|
||||
- **Exposed Port**: 3000
|
||||
- **Volume Mount**: /app/data (for persistent data storage)
|
||||
- **Health Check**: Enabled (checks application availability every 30s)
|
||||
- **Security**: Non-root user, minimal dependencies, security scanning
|
||||
|
||||
The image is publicly available at: https://github.com/nmemmert/spelling/pkgs/container/spelling
|
||||
|
||||
### Backup Data
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# 📱 Tablet Handwriting Recognition Test Deployment
|
||||
|
||||
## 🚀 Quick Deploy
|
||||
|
||||
### Pull and Run the Test Image:
|
||||
```bash
|
||||
# Pull the test image
|
||||
docker pull ghcr.io/nmemmert/spelling:test
|
||||
|
||||
# Run the container
|
||||
docker run -d -p 80:3000 --name spelling-tablet-test ghcr.io/nmemmert/spelling:test
|
||||
```
|
||||
|
||||
### Alternative with Docker Compose:
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
spelling-test:
|
||||
image: ghcr.io/nmemmert/spelling:test
|
||||
ports:
|
||||
- "80:3000"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "test-version=1.2.0-tablet"
|
||||
- "features=handwriting-recognition"
|
||||
```
|
||||
|
||||
## 🖊️ What's Included in This Test Build:
|
||||
|
||||
### ✅ Native Tablet Support:
|
||||
- **Web Handwriting API** integration (Edge/Chrome experimental)
|
||||
- **Windows Ink Platform** compatibility
|
||||
- **Pointer Events API** with pressure sensitivity
|
||||
- **Touch Events API** optimization
|
||||
- **Force Touch** support (3D Touch devices)
|
||||
|
||||
### 🎯 Enhanced Recognition Features:
|
||||
- **Multi-algorithm recognition** chain
|
||||
- **Contextual word matching** for spelling practice
|
||||
- **Pressure-sensitive** stroke analysis
|
||||
- **Timing-based** pattern recognition
|
||||
- **Dictionary-based** post-processing
|
||||
|
||||
### 📝 Tablet-Optimized UI:
|
||||
- **Touch-friendly** interface elements
|
||||
- **Stylus-optimized** canvas
|
||||
- **Responsive** tablet layouts
|
||||
- **Visual feedback** for recognition status
|
||||
- **Auto-detection** of native capabilities
|
||||
|
||||
## 🧪 Testing Instructions:
|
||||
|
||||
### 1. **Access the Application:**
|
||||
- Open your tablet browser
|
||||
- Navigate to your server (e.g., `http://your-server/`)
|
||||
- Look for "📱 Native Tablet Recognition" status
|
||||
|
||||
### 2. **Enable Browser Features (if needed):**
|
||||
- **Edge:** `edge://flags/` → Enable "Experimental Web Platform features"
|
||||
- **Chrome:** `chrome://flags/` → Enable "Experimental Web Platform features"
|
||||
- **Safari:** Enable experimental features in settings
|
||||
|
||||
### 3. **Test Handwriting Recognition:**
|
||||
- Switch to "✏️ Write" mode
|
||||
- Draw letters/words on the canvas with stylus or finger
|
||||
- Click "🔍 Recognize" button
|
||||
- Test words: "Love", "Boy", "Cat", "Make", "Time"
|
||||
|
||||
### 4. **Check Native Features:**
|
||||
- Look for pressure sensitivity (if using stylus)
|
||||
- Test different drawing speeds
|
||||
- Try both print and cursive writing
|
||||
- Check recognition accuracy vs. desktop
|
||||
|
||||
## 📊 Expected Results:
|
||||
|
||||
### **On Modern Tablets:**
|
||||
- ✅ Pointer Events API should work
|
||||
- ✅ Touch Events should be detected
|
||||
- 🔧 Web Handwriting API (needs experimental flags)
|
||||
- ✅ Much better recognition accuracy than desktop
|
||||
|
||||
### **Performance Indicators:**
|
||||
- **Recognition Status:** Should show "Native" or "Enhanced"
|
||||
- **Pressure Data:** Available with compatible stylus
|
||||
- **Touch Optimization:** Smooth drawing experience
|
||||
- **Word Accuracy:** Significantly improved for spelling words
|
||||
|
||||
## 🐛 Troubleshooting:
|
||||
|
||||
### If Recognition is Poor:
|
||||
1. Enable experimental browser features
|
||||
2. Use a compatible stylus (Surface Pen, Apple Pencil, S-Pen)
|
||||
3. Write more slowly and clearly
|
||||
4. Try print letters instead of cursive
|
||||
|
||||
### If No Native Support:
|
||||
- Check browser compatibility
|
||||
- Enable experimental flags
|
||||
- Update to latest browser version
|
||||
- Test on different tablet models
|
||||
|
||||
## 🔗 Image Details:
|
||||
- **Image:** `ghcr.io/nmemmert/spelling:test`
|
||||
- **Version:** `1.2.0-tablet`
|
||||
- **Features:** Native handwriting + Enhanced fallback
|
||||
- **Build Date:** October 2, 2025
|
||||
|
||||
## 📞 Feedback:
|
||||
Test the handwriting recognition and report:
|
||||
- Which device/browser combination
|
||||
- Native API detection results
|
||||
- Recognition accuracy for different words
|
||||
- Any pressure sensitivity detected
|
||||
- Overall user experience improvements
|
||||
@@ -0,0 +1,263 @@
|
||||
# Tablet UI and Handwriting Recognition Features
|
||||
|
||||
This document outlines the new tablet-optimized UI and handwriting recognition features added to the Spelling Practice App.
|
||||
|
||||
## 🎨 Tablet UI Overhaul
|
||||
|
||||
### Features Added
|
||||
|
||||
1. **Tablet-Optimized CSS** (`public/tablet.css`)
|
||||
- Enhanced touch targets (minimum 48px)
|
||||
- Improved typography for tablet viewing
|
||||
- Better spacing and padding for tablet use
|
||||
- Responsive grid layouts
|
||||
- Landscape and portrait orientation support
|
||||
|
||||
2. **Enhanced Student Dashboard**
|
||||
- Card-based activity options with large touch targets
|
||||
- Visual icons and descriptions for each activity
|
||||
- Improved button layouts with better spacing
|
||||
- Better visual hierarchy
|
||||
|
||||
3. **Tablet Utilities** (`public/js/tablet-utils.js`)
|
||||
- Automatic tablet detection
|
||||
- Touch feedback and animations
|
||||
- Swipe gesture support
|
||||
- Orientation change handling
|
||||
- Haptic feedback (where supported)
|
||||
- Enhanced button interactions
|
||||
|
||||
### Tablet-Specific Optimizations
|
||||
|
||||
- **Touch Targets**: All interactive elements are at least 48px in size
|
||||
- **Font Sizes**: Optimized for tablet viewing distances
|
||||
- **Spacing**: Increased padding and margins for better touch interaction
|
||||
- **Visual Feedback**: Touch ripples and scale animations
|
||||
- **Keyboard Optimization**: Prevents zoom on input focus, auto-scroll to inputs
|
||||
|
||||
## ✏️ Handwriting Recognition
|
||||
|
||||
### Features Added
|
||||
|
||||
1. **Handwriting Canvas** (`public/js/handwriting.js`)
|
||||
- HTML5 Canvas for drawing
|
||||
- Multi-touch support
|
||||
- Smooth drawing with configurable pen settings
|
||||
- Stroke recording and playback
|
||||
|
||||
2. **Drawing Controls**
|
||||
- Clear canvas button
|
||||
- Undo last stroke
|
||||
- Adjustable pen size (1-10px)
|
||||
- Color selection
|
||||
- Auto-recognition toggle
|
||||
|
||||
3. **Recognition Engine**
|
||||
- Multiple recognition method support
|
||||
- Basic pattern recognition (fallback)
|
||||
- Integration with Google Vision API (configurable)
|
||||
- MyScript API support (configurable)
|
||||
|
||||
### Input Mode Toggle
|
||||
|
||||
Students can switch between:
|
||||
- **Typing Mode** (⌨️): Traditional keyboard input
|
||||
- **Handwriting Mode** (✏️): Canvas-based writing recognition
|
||||
|
||||
### Handwriting CSS Styles (`public/handwriting.css`)
|
||||
|
||||
- Canvas styling with proper focus indicators
|
||||
- Control buttons with touch-friendly sizing
|
||||
- Recognition status display
|
||||
- Tablet-optimized layouts
|
||||
- Mobile responsive design
|
||||
|
||||
## 🛠️ Implementation Details
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
public/
|
||||
├── tablet.css # Tablet-optimized styles
|
||||
├── handwriting.css # Handwriting recognition styles
|
||||
└── js/
|
||||
├── handwriting.js # Handwriting recognition logic
|
||||
└── tablet-utils.js # Tablet optimization utilities
|
||||
```
|
||||
|
||||
### Key Functions
|
||||
|
||||
#### Tablet Utilities
|
||||
- `detectTablet()`: Automatic device detection
|
||||
- `optimizeButtons()`: Enhanced button interactions
|
||||
- `handleOrientationChange()`: Responsive orientation handling
|
||||
- `addSwipeGestures()`: Navigation gestures
|
||||
|
||||
#### Handwriting Recognition
|
||||
- `startDrawing()`: Begin stroke recording
|
||||
- `draw()`: Track drawing movements
|
||||
- `recognizeText()`: Convert drawing to text
|
||||
- `clearCanvas()`: Reset drawing area
|
||||
- `toggleInputMode()`: Switch between typing/writing
|
||||
|
||||
### Integration Points
|
||||
|
||||
1. **Game Integration**:
|
||||
- `submitAnswer()` function updated to handle both input types
|
||||
- `showWord()` function resets both input methods
|
||||
- Recognition results auto-fill the game input
|
||||
|
||||
2. **UI Enhancement**:
|
||||
- Activity cards with improved touch targets
|
||||
- Input mode toggle in game section
|
||||
- Canvas controls with tablet-friendly sizing
|
||||
|
||||
## 📱 Device Support
|
||||
|
||||
### Tablet Compatibility
|
||||
- iPad (all generations)
|
||||
- Android tablets
|
||||
- Windows tablets
|
||||
- Chrome OS tablets
|
||||
|
||||
### Browser Support
|
||||
- Safari (iOS)
|
||||
- Chrome (Android/Desktop)
|
||||
- Firefox
|
||||
- Edge
|
||||
|
||||
### Touch Features
|
||||
- Multi-touch drawing
|
||||
- Gesture navigation
|
||||
- Haptic feedback (where available)
|
||||
- Orientation detection
|
||||
|
||||
## 🚀 Usage Instructions
|
||||
|
||||
### For Students
|
||||
|
||||
1. **Starting the App**:
|
||||
- Login and access the student dashboard
|
||||
- Choose from improved activity cards
|
||||
|
||||
2. **Spelling Game with Handwriting**:
|
||||
- Start a spelling game
|
||||
- Use the toggle to switch between typing and handwriting
|
||||
- Draw letters on the canvas for recognition
|
||||
- Submit answers using either input method
|
||||
|
||||
3. **Drawing Tips**:
|
||||
- Write clearly with consistent letter sizes
|
||||
- Use print letters rather than cursive
|
||||
- Allow space between letters
|
||||
- Press firmly but write smoothly
|
||||
|
||||
### For Administrators
|
||||
|
||||
The tablet optimizations and handwriting features work seamlessly with existing admin functions:
|
||||
- User management remains unchanged
|
||||
- Word list management improved with better touch targets
|
||||
- Analytics display optimized for tablet viewing
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Handwriting Recognition
|
||||
|
||||
To enable advanced recognition services, configure in `handwriting.js`:
|
||||
|
||||
```javascript
|
||||
// Google Vision API (requires API key)
|
||||
async recognizeWithGoogleVision(imageData) {
|
||||
// Add your Google Vision API implementation
|
||||
}
|
||||
|
||||
// MyScript API (requires API credentials)
|
||||
async recognizeWithMyScript() {
|
||||
// Add your MyScript API implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Tablet Detection
|
||||
|
||||
Tablet detection can be customized in `tablet-utils.js`:
|
||||
|
||||
```javascript
|
||||
detectTablet() {
|
||||
// Customize detection logic based on your requirements
|
||||
const isTouch = 'ontouchstart' in window;
|
||||
const isTabletSize = window.innerWidth >= 768 && window.innerWidth <= 1024;
|
||||
return isTouch && isTabletSize;
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Performance Considerations
|
||||
|
||||
### Optimization Features
|
||||
- Efficient canvas rendering
|
||||
- Minimal DOM manipulation
|
||||
- Event delegation for better performance
|
||||
- Throttled recognition requests
|
||||
- Optimized CSS animations
|
||||
|
||||
### Memory Management
|
||||
- Canvas cleanup on navigation
|
||||
- Event listener removal
|
||||
- Stroke data management
|
||||
- Automatic cleanup of drawing history
|
||||
|
||||
## 🔄 Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
1. **Advanced Recognition**: ML-based handwriting models
|
||||
2. **Multi-language Support**: Recognition for different languages
|
||||
3. **Gesture Shortcuts**: Drawing gestures for common actions
|
||||
4. **Offline Recognition**: Client-side recognition capabilities
|
||||
5. **Collaborative Features**: Shared drawing sessions
|
||||
|
||||
### Accessibility
|
||||
- Voice commands integration
|
||||
- High contrast mode support
|
||||
- Screen reader compatibility
|
||||
- Keyboard navigation fallbacks
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Canvas Not Responding**:
|
||||
- Check if touch events are properly bound
|
||||
- Verify canvas dimensions are set correctly
|
||||
- Ensure no CSS is blocking pointer events
|
||||
|
||||
2. **Recognition Not Working**:
|
||||
- Verify canvas has drawing data
|
||||
- Check console for recognition errors
|
||||
- Ensure fallback recognition is enabled
|
||||
|
||||
3. **Tablet Detection Issues**:
|
||||
- Test on actual tablet devices
|
||||
- Verify user agent detection
|
||||
- Check window dimensions
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug logging by adding to console:
|
||||
```javascript
|
||||
localStorage.setItem('debugHandwriting', 'true');
|
||||
localStorage.setItem('debugTablet', 'true');
|
||||
```
|
||||
|
||||
## 📊 Analytics Integration
|
||||
|
||||
The new features integrate with existing analytics:
|
||||
- Track handwriting vs typing usage
|
||||
- Monitor recognition accuracy
|
||||
- Tablet interaction patterns
|
||||
- Performance metrics
|
||||
|
||||
## 🔒 Privacy and Security
|
||||
|
||||
- Canvas drawings are processed locally when possible
|
||||
- No drawing data is permanently stored
|
||||
- Recognition requests can be configured for privacy compliance
|
||||
- All touch interactions respect user privacy settings
|
||||
@@ -0,0 +1,97 @@
|
||||
# Tablet UI and Handwriting Issues - Troubleshooting Guide
|
||||
|
||||
## Current Status
|
||||
|
||||
### ✅ What's Been Implemented:
|
||||
|
||||
1. **Tablet-Optimized CSS**
|
||||
- `tablet.css` - Comprehensive tablet styles
|
||||
- `handwriting.css` - Canvas and handwriting-specific styles
|
||||
- Enhanced button sizes (min 50px height, larger fonts)
|
||||
- Touch-friendly spacing and padding
|
||||
- Responsive design for tablet viewports
|
||||
|
||||
2. **Handwriting Recognition System**
|
||||
- `handwriting.js` - Full canvas drawing implementation
|
||||
- Touch and mouse event handling
|
||||
- Basic pattern recognition (extendable)
|
||||
- Canvas controls (clear, undo, recognize)
|
||||
|
||||
3. **Enhanced HTML Structure**
|
||||
- Input mode toggle (Type ⌨️ / Write ✏️)
|
||||
- Canvas element with proper sizing
|
||||
- Tablet-friendly activity cards
|
||||
- Improved button layouts
|
||||
|
||||
### ❌ Current Issues Identified:
|
||||
|
||||
1. **Tablet CSS Not Fully Applied**
|
||||
- Media query might be too restrictive
|
||||
- Some styles being overridden by existing CSS
|
||||
- Need better tablet detection
|
||||
|
||||
2. **Handwriting Interface Not Visible**
|
||||
- Canvas might not be properly initialized
|
||||
- Toggle function might not be working
|
||||
- CSS display properties conflicting
|
||||
|
||||
## Quick Fixes Needed:
|
||||
|
||||
### Fix 1: Improve Tablet CSS Application
|
||||
```css
|
||||
/* Change media query to be more inclusive */
|
||||
@media screen and (min-width: 600px), (pointer: coarse) {
|
||||
/* Tablet styles */
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 2: Force Tablet Styles in Main CSS
|
||||
Add `!important` declarations to critical tablet styles in `styles.css`
|
||||
|
||||
### Fix 3: Debug Canvas Initialization
|
||||
Add console logging to track canvas setup process
|
||||
|
||||
### Fix 4: Simplify Toggle Function
|
||||
Make input mode switching more reliable with explicit show/hide
|
||||
|
||||
## Immediate Action Plan:
|
||||
|
||||
1. **Test the separate tablet-test.html page** - This shows how it should work
|
||||
2. **Fix CSS conflicts** in the main app
|
||||
3. **Improve canvas initialization** with better error handling
|
||||
4. **Add debug logging** to track what's happening
|
||||
5. **Ensure student login works** to access handwriting features
|
||||
|
||||
## Files to Check:
|
||||
|
||||
- `public/index.html` - HTML structure
|
||||
- `public/styles.css` - Main styles (may need tablet overrides)
|
||||
- `public/tablet.css` - Tablet-specific styles
|
||||
- `public/handwriting.css` - Canvas styles
|
||||
- `public/js/handwriting.js` - Canvas functionality
|
||||
- `public/js/tablet-utils.js` - Tablet optimizations
|
||||
|
||||
## Testing Steps:
|
||||
|
||||
1. Open `tablet-test.html` to see working example
|
||||
2. Login as student (nate/nate123 or student1/123456)
|
||||
3. Start spelling game
|
||||
4. Check browser console for errors
|
||||
5. Try toggling between type/write modes
|
||||
6. Test canvas drawing if handwriting mode works
|
||||
|
||||
## Browser Console Commands for Testing:
|
||||
|
||||
```javascript
|
||||
// Check if handwriting is initialized
|
||||
console.log(window.handwritingRecognition);
|
||||
|
||||
// Check canvas element
|
||||
console.log(document.getElementById('handwritingCanvas'));
|
||||
|
||||
// Test toggle function
|
||||
window.toggleInputMode('handwriting');
|
||||
|
||||
// Check CSS loading
|
||||
console.log(document.querySelector('link[href*="tablet.css"]'));
|
||||
```
|
||||
@@ -0,0 +1,116 @@
|
||||
# PowerShell script to build and push Docker image with "test" tag for tablet testing
|
||||
|
||||
# Set variables
|
||||
$IMAGE_NAME = "ghcr.io/nmemmert/spelling"
|
||||
$TEST_TAG = "test"
|
||||
$VERSION = (Get-Content -Path "version.json" | ConvertFrom-Json).version
|
||||
|
||||
Write-Host "🖊️ Building Docker image for TABLET HANDWRITING TESTING..." -ForegroundColor Magenta
|
||||
Write-Host "📦 Image: ${IMAGE_NAME}:${TEST_TAG}" -ForegroundColor Cyan
|
||||
Write-Host "🔖 Base Version: ${VERSION}" -ForegroundColor Cyan
|
||||
|
||||
# Add test-specific metadata to the build
|
||||
$BUILD_DATE = Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"
|
||||
$GIT_COMMIT = git rev-parse --short HEAD 2>$null
|
||||
if (-not $GIT_COMMIT) { $GIT_COMMIT = "unknown" }
|
||||
|
||||
Write-Host "📝 Git Commit: $GIT_COMMIT" -ForegroundColor Yellow
|
||||
Write-Host "📅 Build Date: $BUILD_DATE" -ForegroundColor Yellow
|
||||
|
||||
# Build the image with test tag and labels
|
||||
Write-Host "🔨 Building Docker image..." -ForegroundColor Cyan
|
||||
docker build `
|
||||
--label "version=${VERSION}-test" `
|
||||
--label "build-date=${BUILD_DATE}" `
|
||||
--label "git-commit=${GIT_COMMIT}" `
|
||||
--label "tablet-features=enabled" `
|
||||
--label "handwriting-recognition=native+enhanced" `
|
||||
-t ${IMAGE_NAME}:${TEST_TAG} `
|
||||
-t ${IMAGE_NAME}:${VERSION}-test `
|
||||
.
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "❌ Docker build failed!" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✅ Build completed." -ForegroundColor Green
|
||||
Write-Host "🧪 Testing the image..." -ForegroundColor Cyan
|
||||
|
||||
# Run container for testing
|
||||
docker run --name spelling-test-tablet -d -p 3002:3000 ${IMAGE_NAME}:${TEST_TAG}
|
||||
|
||||
# Wait for container to start
|
||||
Write-Host "⏱️ Waiting for container to start..." -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds 8
|
||||
|
||||
# Test health endpoint
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri "http://localhost:3002/health" -UseBasicParsing -TimeoutSec 10
|
||||
if ($response.StatusCode -eq 200) {
|
||||
Write-Host "✅ Health check passed!" -ForegroundColor Green
|
||||
|
||||
# Test main page
|
||||
$mainResponse = Invoke-WebRequest -Uri "http://localhost:3002/" -UseBasicParsing -TimeoutSec 10
|
||||
if ($mainResponse.StatusCode -eq 200) {
|
||||
Write-Host "✅ Main page accessible!" -ForegroundColor Green
|
||||
|
||||
# Check for handwriting features in the HTML
|
||||
if ($mainResponse.Content -like "*native-handwriting.js*") {
|
||||
Write-Host "🖊️ Native handwriting support detected!" -ForegroundColor Green
|
||||
}
|
||||
if ($mainResponse.Content -like "*handwriting-canvas*") {
|
||||
Write-Host "🎨 Handwriting canvas found!" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Host "❌ Health check failed with status $($response.StatusCode)" -ForegroundColor Red
|
||||
docker logs spelling-test-tablet
|
||||
docker stop spelling-test-tablet
|
||||
docker rm spelling-test-tablet
|
||||
exit 1
|
||||
}
|
||||
} catch {
|
||||
Write-Host "❌ Health check failed: $_" -ForegroundColor Red
|
||||
Write-Host "📋 Container logs:" -ForegroundColor Yellow
|
||||
docker logs spelling-test-tablet
|
||||
docker stop spelling-test-tablet
|
||||
docker rm spelling-test-tablet
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Stop and remove test container
|
||||
docker stop spelling-test-tablet
|
||||
docker rm spelling-test-tablet
|
||||
|
||||
Write-Host "📤 Pushing image to GitHub Container Registry..." -ForegroundColor Cyan
|
||||
Write-Host "🏷️ Pushing tags: ${TEST_TAG} and ${VERSION}-test" -ForegroundColor Yellow
|
||||
|
||||
docker push ${IMAGE_NAME}:${TEST_TAG}
|
||||
docker push ${IMAGE_NAME}:${VERSION}-test
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "" -ForegroundColor Green
|
||||
Write-Host "🚀 Successfully built and pushed TABLET TEST image!" -ForegroundColor Green
|
||||
Write-Host "" -ForegroundColor White
|
||||
Write-Host "📱 TABLET TESTING INSTRUCTIONS:" -ForegroundColor Magenta
|
||||
Write-Host "=================================" -ForegroundColor Magenta
|
||||
Write-Host "1. Deploy using: docker pull ${IMAGE_NAME}:${TEST_TAG}" -ForegroundColor White
|
||||
Write-Host "2. Run on tablet-accessible server" -ForegroundColor White
|
||||
Write-Host "3. Test handwriting recognition with stylus/touch" -ForegroundColor White
|
||||
Write-Host "4. Features included:" -ForegroundColor White
|
||||
Write-Host " • Native tablet handwriting APIs" -ForegroundColor Cyan
|
||||
Write-Host " • Pressure-sensitive stylus support" -ForegroundColor Cyan
|
||||
Write-Host " • Enhanced pattern recognition" -ForegroundColor Cyan
|
||||
Write-Host " • Touch event optimization" -ForegroundColor Cyan
|
||||
Write-Host " • Pointer Events API integration" -ForegroundColor Cyan
|
||||
Write-Host "" -ForegroundColor White
|
||||
Write-Host "🔗 Image: ${IMAGE_NAME}:${TEST_TAG}" -ForegroundColor Yellow
|
||||
Write-Host "" -ForegroundColor White
|
||||
} else {
|
||||
Write-Host "❌ Push failed!" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Press any key to exit..."
|
||||
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
+45
-7
@@ -1,11 +1,49 @@
|
||||
{
|
||||
"nate": [
|
||||
"Perfect Round",
|
||||
"No Misses",
|
||||
"Accuracy Master",
|
||||
"Practice Streak",
|
||||
"Vocabulary Pro"
|
||||
],
|
||||
"nate": {
|
||||
"earned": [
|
||||
{
|
||||
"id": "perfect_round",
|
||||
"name": "Perfect Round",
|
||||
"icon": "🏅",
|
||||
"earnedAt": "2025-08-14T13:36:30.690Z"
|
||||
},
|
||||
{
|
||||
"id": "no_misses",
|
||||
"name": "No Misses",
|
||||
"icon": "🏅",
|
||||
"earnedAt": "2025-08-14T13:36:30.690Z"
|
||||
},
|
||||
{
|
||||
"id": "accuracy_master",
|
||||
"name": "Accuracy Master",
|
||||
"icon": "🏅",
|
||||
"earnedAt": "2025-08-14T13:36:30.690Z"
|
||||
},
|
||||
{
|
||||
"id": "practice_streak",
|
||||
"name": "Practice Streak",
|
||||
"icon": "🏅",
|
||||
"earnedAt": "2025-08-14T13:36:30.690Z"
|
||||
},
|
||||
{
|
||||
"id": "vocabulary_pro",
|
||||
"name": "Vocabulary Pro",
|
||||
"icon": "🏅",
|
||||
"earnedAt": "2025-08-14T13:36:30.690Z"
|
||||
},
|
||||
{
|
||||
"id": "perfect_accuracy",
|
||||
"name": "Perfect Score",
|
||||
"icon": "🏆",
|
||||
"description": "You scored 100% on your spelling test!",
|
||||
"category": "accuracy",
|
||||
"earnedAt": "2025-08-14T13:36:30.690Z"
|
||||
}
|
||||
],
|
||||
"counts": {
|
||||
"accuracy": 1
|
||||
}
|
||||
},
|
||||
"admin1": {
|
||||
"earned": [
|
||||
{
|
||||
|
||||
+47
-1
@@ -16,7 +16,53 @@
|
||||
}
|
||||
],
|
||||
"student1": [],
|
||||
"nate": [],
|
||||
"nate": [
|
||||
{
|
||||
"score": 3,
|
||||
"completed": true,
|
||||
"answers": [
|
||||
{
|
||||
"word": "bate",
|
||||
"input": "bate",
|
||||
"correct": true
|
||||
},
|
||||
{
|
||||
"word": "hate",
|
||||
"input": "hate",
|
||||
"correct": true
|
||||
},
|
||||
{
|
||||
"word": "late",
|
||||
"input": "late",
|
||||
"correct": true
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-08-14T13:36:05.098Z",
|
||||
"type": "typing"
|
||||
},
|
||||
{
|
||||
"score": 100,
|
||||
"completed": true,
|
||||
"answers": [
|
||||
{
|
||||
"word": "bate",
|
||||
"attempt": "bate",
|
||||
"correct": true
|
||||
},
|
||||
{
|
||||
"word": "hate",
|
||||
"attempt": "hate",
|
||||
"correct": true
|
||||
},
|
||||
{
|
||||
"word": "late",
|
||||
"attempt": "late",
|
||||
"correct": true
|
||||
}
|
||||
],
|
||||
"timestamp": "2025-08-14T13:36:30.685Z"
|
||||
}
|
||||
],
|
||||
"testuser": [
|
||||
{
|
||||
"score": 85,
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
// Debug script to test the /getUsers endpoint
|
||||
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
console.log('Testing /getUsers endpoint...');
|
||||
|
||||
const response = await fetch('http://localhost:3000/getUsers');
|
||||
console.log(`STATUS: ${response.status} ${response.statusText}`);
|
||||
|
||||
const headers = {};
|
||||
response.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
console.log('HEADERS:', headers);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`⚠️ API responded with ${response.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
console.log('Response body length:', text.length);
|
||||
|
||||
try {
|
||||
if (text.length > 0) {
|
||||
const users = JSON.parse(text);
|
||||
console.log('✅ Response contains valid JSON');
|
||||
if (Array.isArray(users)) {
|
||||
console.log(`Found ${users.length} users:`, users.map(u => u.username));
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log('⚠️ WARNING: No users returned from API');
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ Response is not an array:', users);
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ Empty response received');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ ERROR: Invalid JSON in response:', e.message);
|
||||
console.log('Raw response:', text.substring(0, 100) + (text.length > 100 ? '...' : ''));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ ERROR: ${error.message}`);
|
||||
}
|
||||
})();
|
||||
@@ -1,59 +0,0 @@
|
||||
// Debug script to test the /getUsers endpoint
|
||||
const http = require('http');
|
||||
|
||||
console.log('Testing /getUsers endpoint...');
|
||||
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3000,
|
||||
path: '/getUsers',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
console.log(`STATUS: ${res.statusCode}`);
|
||||
console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
|
||||
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
console.log('Response body length:', data.length);
|
||||
|
||||
try {
|
||||
if (data.length > 0) {
|
||||
const users = JSON.parse(data);
|
||||
console.log('✅ Response contains valid JSON');
|
||||
if (Array.isArray(users)) {
|
||||
console.log(`Found ${users.length} users:`, users.map(u => u.username));
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log('⚠️ WARNING: No users returned from API');
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ Response is not an array:', users);
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ Empty response received');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ ERROR: Invalid JSON in response:', e.message);
|
||||
console.log('Raw response:', data.substring(0, 100) + (data.length > 100 ? '...' : ''));
|
||||
}
|
||||
|
||||
process.exit(0); // Exit successfully
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => {
|
||||
console.error(`❌ ERROR: ${e.message}`);
|
||||
process.exit(1); // Exit with error
|
||||
});
|
||||
|
||||
req.end();
|
||||
@@ -1,17 +0,0 @@
|
||||
// Debug script to check password hashes
|
||||
const { createHash } = require('crypto');
|
||||
|
||||
function hashPassword(password) {
|
||||
return createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
console.log('Testing password hashes:');
|
||||
console.log('password -> ', hashPassword('password'));
|
||||
console.log('123456 -> ', hashPassword('123456'));
|
||||
console.log('test -> ', hashPassword('test'));
|
||||
|
||||
// Known hashes from users.json:
|
||||
console.log('\nExpected hashes from users.json:');
|
||||
console.log('admin1 (password): 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8');
|
||||
console.log('student1 (123456): 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92');
|
||||
console.log('nate: 5a2a558c78d3717db731600c4f354fa1d9c84b556f108091a891f444f1bdec40');
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Debug utility to check the results.json file and report any issues
|
||||
* Run with: node debug-results.js
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const RESULTS_FILE = path.join(DATA_DIR, 'results.json');
|
||||
|
||||
console.log('🔍 Debug Results.json Utility');
|
||||
console.log('============================');
|
||||
|
||||
// Check if the file exists
|
||||
console.log(`\n1. Checking if results.json exists at: ${RESULTS_FILE}`);
|
||||
if (fs.existsSync(RESULTS_FILE)) {
|
||||
console.log('✅ File exists');
|
||||
|
||||
// Check file size
|
||||
const stats = fs.statSync(RESULTS_FILE);
|
||||
console.log(`📊 File size: ${stats.size} bytes`);
|
||||
|
||||
// Check file permissions
|
||||
console.log(`📊 File permissions: ${(stats.mode & 0o777).toString(8)}`);
|
||||
|
||||
// Try to read the file
|
||||
console.log('\n2. Attempting to read the file');
|
||||
try {
|
||||
const fileContent = fs.readFileSync(RESULTS_FILE, 'utf8');
|
||||
console.log(`✅ File read successfully (${fileContent.length} characters)`);
|
||||
|
||||
// Check for empty file
|
||||
if (fileContent.trim() === '') {
|
||||
console.log('⚠️ Warning: File is empty');
|
||||
|
||||
// Create a valid empty structure
|
||||
fs.writeFileSync(RESULTS_FILE, JSON.stringify({}, null, 2));
|
||||
console.log('✅ Created valid empty structure');
|
||||
} else {
|
||||
// Try to parse the JSON
|
||||
console.log('\n3. Attempting to parse JSON');
|
||||
try {
|
||||
const data = JSON.parse(fileContent);
|
||||
console.log('✅ JSON parsed successfully');
|
||||
|
||||
// Check the structure
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
console.log('❌ Error: Root is not an object');
|
||||
} else {
|
||||
const userCount = Object.keys(data).length;
|
||||
console.log(`📊 Contains data for ${userCount} users`);
|
||||
|
||||
// Display some stats about each user's sessions
|
||||
Object.entries(data).forEach(([username, sessions]) => {
|
||||
if (!Array.isArray(sessions)) {
|
||||
console.log(`❌ Error: Sessions for ${username} is not an array`);
|
||||
} else {
|
||||
console.log(`📊 ${username}: ${sessions.length} sessions`);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.log(`❌ Error parsing JSON: ${parseError.message}`);
|
||||
|
||||
// Try to identify the location of the error
|
||||
const errorMatch = parseError.message.match(/position (\d+)/);
|
||||
if (errorMatch && errorMatch[1]) {
|
||||
const position = parseInt(errorMatch[1]);
|
||||
const start = Math.max(0, position - 20);
|
||||
const end = Math.min(fileContent.length, position + 20);
|
||||
console.log(`Error near: ...${fileContent.substring(start, position)}[HERE]${fileContent.substring(position, end)}...`);
|
||||
}
|
||||
|
||||
// If the file seems to be corrupted, offer to create a new one
|
||||
console.log('\nThe file appears to be corrupted. Do you want to reset it to a valid empty structure?');
|
||||
console.log('To reset, run: node -e "require(\'fs\').writeFileSync(\'./data/results.json\', JSON.stringify({}, null, 2))"');
|
||||
}
|
||||
}
|
||||
} catch (readError) {
|
||||
console.log(`❌ Error reading file: ${readError.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log('❌ File does not exist');
|
||||
|
||||
// Create the file with a valid empty structure
|
||||
try {
|
||||
// Make sure the data directory exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
console.log(`✅ Created data directory at: ${DATA_DIR}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(RESULTS_FILE, JSON.stringify({}, null, 2));
|
||||
console.log('✅ Created file with valid empty structure');
|
||||
} catch (createError) {
|
||||
console.log(`❌ Error creating file: ${createError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nDone!');
|
||||
@@ -1,26 +0,0 @@
|
||||
// Very simple test to fetch from our own server
|
||||
const http = require('http');
|
||||
|
||||
console.log('Testing server...');
|
||||
|
||||
try {
|
||||
// Simple HTTP GET request
|
||||
http.get('http://localhost:3000/', (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
console.log('Status:', res.statusCode);
|
||||
console.log('Response length:', data.length);
|
||||
console.log('Response preview:', data.substring(0, 100) + '...');
|
||||
});
|
||||
|
||||
}).on('error', (err) => {
|
||||
console.error('Error connecting to server:', err.message);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to make request:', error);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// Debug utility to check if users.json exists
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const SEED_DIR = path.join(__dirname, 'seed');
|
||||
const usersFile = path.join(DATA_DIR, 'users.json');
|
||||
|
||||
console.log('Checking for users.json file...');
|
||||
console.log(`DATA_DIR: ${DATA_DIR}`);
|
||||
|
||||
// Check if data directory exists
|
||||
if (fs.existsSync(DATA_DIR)) {
|
||||
console.log('✅ Data directory exists');
|
||||
} else {
|
||||
console.log('❌ Data directory does not exist!');
|
||||
console.log('Creating data directory...');
|
||||
fs.mkdirSync(DATA_DIR);
|
||||
}
|
||||
|
||||
// Check if users.json exists
|
||||
if (fs.existsSync(usersFile)) {
|
||||
console.log('✅ users.json exists');
|
||||
|
||||
// Read file and check content
|
||||
try {
|
||||
const content = fs.readFileSync(usersFile, 'utf-8');
|
||||
console.log('File content length:', content.length);
|
||||
|
||||
try {
|
||||
const users = JSON.parse(content);
|
||||
console.log('✅ users.json contains valid JSON');
|
||||
console.log(`Found ${users.length} users:`, users.map(u => u.username));
|
||||
} catch (e) {
|
||||
console.log('❌ users.json contains invalid JSON:', e.message);
|
||||
console.log('File content:', content);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('❌ Could not read users.json:', e.message);
|
||||
}
|
||||
} else {
|
||||
console.log('❌ users.json does not exist!');
|
||||
|
||||
// Check if seed exists
|
||||
const seedFile = path.join(SEED_DIR, 'users.json');
|
||||
if (fs.existsSync(seedFile)) {
|
||||
console.log('✅ Seed users.json exists');
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(seedFile, 'utf-8');
|
||||
console.log('Copying seed to data directory...');
|
||||
fs.writeFileSync(usersFile, content);
|
||||
console.log('✅ Seed copied to data directory');
|
||||
} catch (e) {
|
||||
console.log('❌ Could not copy seed users.json:', e.message);
|
||||
}
|
||||
} else {
|
||||
console.log('❌ Seed users.json does not exist!');
|
||||
|
||||
// Create default users
|
||||
const defaultUsers = [
|
||||
{
|
||||
"username": "admin1",
|
||||
"hash": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", // password
|
||||
"role": "admin"
|
||||
},
|
||||
{
|
||||
"username": "nate",
|
||||
"hash": "5a2a558c78d3717db731600c4f354fa1d9c84b556f108091a891f444f1bdec40", // nate123
|
||||
"role": "student"
|
||||
},
|
||||
{
|
||||
"username": "student1",
|
||||
"hash": "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", // 123456
|
||||
"role": "student"
|
||||
}
|
||||
];
|
||||
|
||||
console.log('Creating default users.json...');
|
||||
fs.writeFileSync(usersFile, JSON.stringify(defaultUsers, null, 2));
|
||||
console.log('✅ Default users.json created');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# PowerShell script for Docker build and push with GitHub authentication
|
||||
# This script will build and push your Docker image to GitHub Container Registry
|
||||
# You must set the CR_PAT environment variable with your GitHub Personal Access Token
|
||||
|
||||
# Check if CR_PAT environment variable is set
|
||||
if (-not $env:CR_PAT) {
|
||||
Write-Host "❌ Error: CR_PAT environment variable is not set." -ForegroundColor Red
|
||||
Write-Host "Please set your GitHub Personal Access Token as an environment variable:" -ForegroundColor Yellow
|
||||
Write-Host '$env:CR_PAT = "YOUR_GITHUB_PAT"' -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Set variables
|
||||
$IMAGE_NAME = "ghcr.io/nmemmert/spelling"
|
||||
$VERSION = (Get-Content -Path "version.json" | ConvertFrom-Json).version
|
||||
$GITHUB_USERNAME = "nmemmert"
|
||||
|
||||
Write-Host "🔑 Logging in to GitHub Container Registry..." -ForegroundColor Cyan
|
||||
# Login to GitHub Container Registry using environment variable
|
||||
$env:CR_PAT | docker login ghcr.io -u $GITHUB_USERNAME --password-stdin
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "❌ Login failed. Please check your GitHub token." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✅ Successfully logged in to GitHub Container Registry." -ForegroundColor Green
|
||||
Write-Host "🔨 Building Docker image ${IMAGE_NAME}:${VERSION}..." -ForegroundColor Cyan
|
||||
|
||||
# Build the image
|
||||
docker build -t ${IMAGE_NAME}:${VERSION} -t ${IMAGE_NAME}:latest .
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "❌ Docker build failed." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✅ Build completed." -ForegroundColor Green
|
||||
Write-Host "📤 Pushing image to GitHub Container Registry..." -ForegroundColor Cyan
|
||||
|
||||
# Push both tags to registry
|
||||
docker push ${IMAGE_NAME}:${VERSION}
|
||||
docker push ${IMAGE_NAME}:latest
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "❌ Docker push failed." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✅ Successfully pushed ${IMAGE_NAME}:${VERSION} and ${IMAGE_NAME}:latest" -ForegroundColor Green
|
||||
Write-Host "🎉 Done! Your Docker image is now available at ${IMAGE_NAME}:${VERSION}" -ForegroundColor Cyan
|
||||
@@ -1,373 +0,0 @@
|
||||
/**
|
||||
* Generate sample analytics data for the Spelling Practice App
|
||||
* This script will add realistic user activity data to showcase the analytics features
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Define paths
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const files = {
|
||||
users: path.join(DATA_DIR, 'users.json'),
|
||||
results: path.join(DATA_DIR, 'results.json'),
|
||||
progress: path.join(DATA_DIR, 'progress.json'),
|
||||
leaderboards: path.join(DATA_DIR, 'leaderboards.json'),
|
||||
challenges: path.join(DATA_DIR, 'challenges.json')
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
function readJsonSafe(filePath, defaultValue = {}) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
return defaultValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonSafe(filePath, data) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error writing to ${filePath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Random helpers
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
function randomFloat(min, max, decimals = 2) {
|
||||
const rand = Math.random() * (max - min) + min;
|
||||
return parseFloat(rand.toFixed(decimals));
|
||||
}
|
||||
|
||||
// Date helpers
|
||||
function addDays(date, days) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
function subtractDays(date, days) {
|
||||
return addDays(date, -days);
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function generateTimeOfDay() {
|
||||
const hours = randomInt(8, 20).toString().padStart(2, '0');
|
||||
const minutes = randomInt(0, 59).toString().padStart(2, '0');
|
||||
const seconds = randomInt(0, 59).toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
// Main function to generate analytics data
|
||||
async function generateAnalyticsData() {
|
||||
console.log('Generating sample analytics data...');
|
||||
|
||||
// Load users
|
||||
const users = readJsonSafe(files.users, []);
|
||||
if (users.length === 0) {
|
||||
console.error('No users found. Please ensure users exist before running this script.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate data for each user
|
||||
const usernames = users.map(user => user.username);
|
||||
|
||||
// Generate 90 days of results data (covering 3 months)
|
||||
const today = new Date();
|
||||
|
||||
// Load existing data
|
||||
const resultsData = readJsonSafe(files.results, {});
|
||||
const progressData = readJsonSafe(files.progress, {});
|
||||
|
||||
// For each user, generate 90 days of activity
|
||||
for (const username of usernames) {
|
||||
console.log(`Generating data for user: ${username}`);
|
||||
|
||||
// Initialize user results if not exists
|
||||
if (!resultsData[username]) {
|
||||
resultsData[username] = [];
|
||||
}
|
||||
|
||||
// Initialize user progress if not exists
|
||||
if (!progressData[username]) {
|
||||
progressData[username] = {
|
||||
stats: {
|
||||
points: 0,
|
||||
totalSessions: 0,
|
||||
totalWords: 0,
|
||||
correctWords: 0,
|
||||
accuracy: 0
|
||||
},
|
||||
streaks: {
|
||||
current: 0,
|
||||
longest: 0,
|
||||
dates: []
|
||||
},
|
||||
challengesCompleted: {
|
||||
daily: [],
|
||||
weekly: [],
|
||||
achievements: []
|
||||
},
|
||||
activityLog: []
|
||||
};
|
||||
}
|
||||
|
||||
// Track total stats
|
||||
let totalSessions = 0;
|
||||
let totalWords = 0;
|
||||
let totalCorrect = 0;
|
||||
let streakDates = [];
|
||||
let activityLog = [];
|
||||
let points = randomInt(100, 500);
|
||||
|
||||
// Generate 90 days of activity with increasing frequency towards present day
|
||||
for (let daysAgo = 90; daysAgo >= 0; daysAgo--) {
|
||||
const activityDate = subtractDays(today, daysAgo);
|
||||
const dateStr = formatDate(activityDate);
|
||||
|
||||
// Determine likelihood of activity on this day
|
||||
// Higher chance of activity for more recent dates and on weekdays
|
||||
const dayOfWeek = activityDate.getDay(); // 0 = Sunday, 6 = Saturday
|
||||
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||
const recencyFactor = 1 - (daysAgo / 90); // 0 to 1 from oldest to newest
|
||||
|
||||
// Combine factors to determine chance of activity
|
||||
// 30% base chance + up to 50% recency bonus - 20% weekend penalty
|
||||
let activityChance = 0.3 + (recencyFactor * 0.5) - (isWeekend ? 0.2 : 0);
|
||||
|
||||
// For user "nate", increase activity chance to ensure good data
|
||||
if (username === 'nate') {
|
||||
activityChance += 0.2;
|
||||
}
|
||||
|
||||
// Determine if there's activity on this day
|
||||
if (Math.random() < activityChance) {
|
||||
// Record streak date
|
||||
streakDates.push(dateStr);
|
||||
|
||||
// Determine number of sessions for this day (1-3)
|
||||
const sessionsToday = randomInt(1, username === 'nate' ? 3 : 2);
|
||||
|
||||
for (let s = 0; s < sessionsToday; s++) {
|
||||
// Generate session ID
|
||||
const sessionId = `${username}-${dateStr}-${s}`;
|
||||
|
||||
// Determine session details
|
||||
const numWords = randomInt(5, 15);
|
||||
|
||||
// Base accuracy that improves over time due to learning
|
||||
let baseAccuracy = 0.65 + (recencyFactor * 0.2);
|
||||
|
||||
// Add some randomness
|
||||
const accuracy = Math.min(0.98, baseAccuracy + randomFloat(-0.1, 0.1));
|
||||
|
||||
// Calculate correct words
|
||||
const correctWords = Math.round(numWords * accuracy);
|
||||
|
||||
// Generate random words
|
||||
const words = Array.from({length: numWords}, (_, i) => {
|
||||
const isCorrect = i < correctWords;
|
||||
return {
|
||||
word: `sample-word-${randomInt(1, 100)}`,
|
||||
correct: isCorrect
|
||||
};
|
||||
});
|
||||
|
||||
// Create session result
|
||||
const sessionResult = {
|
||||
date: `${dateStr}T${generateTimeOfDay()}Z`,
|
||||
sessionId,
|
||||
total: numWords,
|
||||
correct: correctWords,
|
||||
score: Math.round(accuracy * 100),
|
||||
words
|
||||
};
|
||||
|
||||
// Add to results
|
||||
resultsData[username].push(sessionResult);
|
||||
|
||||
// Update totals
|
||||
totalSessions++;
|
||||
totalWords += numWords;
|
||||
totalCorrect += correctWords;
|
||||
|
||||
// Add to activity log
|
||||
activityLog.push({
|
||||
type: 'session_completed',
|
||||
timestamp: `${dateStr}T${generateTimeOfDay()}Z`,
|
||||
details: {
|
||||
sessionId,
|
||||
score: Math.round(accuracy * 100),
|
||||
words: numWords
|
||||
}
|
||||
});
|
||||
|
||||
// Occasionally add badge earning (1 in 10 sessions)
|
||||
if (Math.random() < 0.1) {
|
||||
const badges = [
|
||||
"Perfect Round", "Speed Demon", "Word Wizard", "Consistent Learner",
|
||||
"Weekly Warrior", "Quick Study", "No Misses"
|
||||
];
|
||||
const badge = badges[randomInt(0, badges.length - 1)];
|
||||
|
||||
activityLog.push({
|
||||
type: 'badge_earned',
|
||||
timestamp: `${dateStr}T${generateTimeOfDay()}Z`,
|
||||
details: {
|
||||
badge,
|
||||
sessionId
|
||||
}
|
||||
});
|
||||
|
||||
points += randomInt(10, 50);
|
||||
}
|
||||
|
||||
// Occasionally add challenge completion (1 in 8 sessions)
|
||||
if (Math.random() < 0.125) {
|
||||
const challengeTypes = ['daily', 'weekly', 'achievements'];
|
||||
const challengeType = challengeTypes[randomInt(0, challengeTypes.length - 1)];
|
||||
const challengeId = `${challengeType}-${dateStr}-${randomInt(1, 5)}`;
|
||||
|
||||
// Add to completed challenges
|
||||
if (!progressData[username].challengesCompleted[challengeType].includes(challengeId)) {
|
||||
progressData[username].challengesCompleted[challengeType].push(challengeId);
|
||||
}
|
||||
|
||||
activityLog.push({
|
||||
type: 'challenge_completed',
|
||||
timestamp: `${dateStr}T${generateTimeOfDay()}Z`,
|
||||
details: {
|
||||
challengeId,
|
||||
challengeType,
|
||||
reward: {
|
||||
points: randomInt(10, 50)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
points += randomInt(20, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate streak stats
|
||||
const streakDays = streakDates.length;
|
||||
const currentStreak = calculateCurrentStreak(streakDates, today);
|
||||
const longestStreak = calculateLongestStreak(streakDates);
|
||||
|
||||
// Update progress data with calculated stats
|
||||
progressData[username].stats = {
|
||||
points,
|
||||
totalSessions,
|
||||
totalWords,
|
||||
correctWords: totalCorrect,
|
||||
accuracy: totalWords > 0 ? Math.round((totalCorrect / totalWords) * 1000) / 10 : 0
|
||||
};
|
||||
|
||||
progressData[username].streaks = {
|
||||
current: currentStreak,
|
||||
longest: longestStreak,
|
||||
dates: streakDates
|
||||
};
|
||||
|
||||
progressData[username].activityLog = activityLog;
|
||||
}
|
||||
|
||||
// Save updated data
|
||||
console.log('Saving updated results data...');
|
||||
writeJsonSafe(files.results, resultsData);
|
||||
|
||||
console.log('Saving updated progress data...');
|
||||
writeJsonSafe(files.progress, progressData);
|
||||
|
||||
console.log('Sample data generation complete!');
|
||||
}
|
||||
|
||||
// Helper to calculate current streak
|
||||
function calculateCurrentStreak(dates, today) {
|
||||
if (!dates || dates.length === 0) return 0;
|
||||
|
||||
// Convert dates to Date objects for easier comparison
|
||||
const sortedDates = [...dates].map(d => new Date(d)).sort((a, b) => b - a);
|
||||
|
||||
// Check if today is included
|
||||
const todayStr = formatDate(today);
|
||||
const hasTodayActivity = dates.includes(todayStr);
|
||||
|
||||
if (!hasTodayActivity) {
|
||||
// Check if yesterday is included
|
||||
const yesterday = subtractDays(today, 1);
|
||||
const yesterdayStr = formatDate(yesterday);
|
||||
const hasYesterdayActivity = dates.includes(yesterdayStr);
|
||||
|
||||
if (!hasYesterdayActivity) {
|
||||
return 0; // Streak broken if neither today nor yesterday has activity
|
||||
}
|
||||
}
|
||||
|
||||
// Count consecutive days
|
||||
let streak = hasTodayActivity ? 1 : 0;
|
||||
let currentDate = hasTodayActivity ? subtractDays(today, 1) : subtractDays(today, 2);
|
||||
|
||||
while (true) {
|
||||
const dateStr = formatDate(currentDate);
|
||||
if (!dates.includes(dateStr)) {
|
||||
break;
|
||||
}
|
||||
streak++;
|
||||
currentDate = subtractDays(currentDate, 1);
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
// Helper to calculate longest streak
|
||||
function calculateLongestStreak(dates) {
|
||||
if (!dates || dates.length === 0) return 0;
|
||||
|
||||
// Convert dates to Date objects and sort
|
||||
const sortedDates = [...dates].map(d => new Date(d)).sort((a, b) => a - b);
|
||||
|
||||
let longestStreak = 1;
|
||||
let currentStreak = 1;
|
||||
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const prevDate = sortedDates[i - 1];
|
||||
const currentDate = sortedDates[i];
|
||||
|
||||
// Calculate difference in days
|
||||
const diffTime = currentDate.getTime() - prevDate.getTime();
|
||||
const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
// Consecutive day
|
||||
currentStreak++;
|
||||
if (currentStreak > longestStreak) {
|
||||
longestStreak = currentStreak;
|
||||
}
|
||||
} else if (diffDays > 1) {
|
||||
// Streak broken
|
||||
currentStreak = 1;
|
||||
}
|
||||
}
|
||||
|
||||
return longestStreak;
|
||||
}
|
||||
|
||||
// Execute the script
|
||||
generateAnalyticsData();
|
||||
@@ -1,285 +0,0 @@
|
||||
/**
|
||||
* Generate sample gamification data for the Spelling Practice App
|
||||
* This script will populate challenges and user progress with realistic data
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Define paths
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const files = {
|
||||
users: path.join(DATA_DIR, 'users.json'),
|
||||
challenges: path.join(DATA_DIR, 'challenges.json'),
|
||||
progress: path.join(DATA_DIR, 'progress.json')
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
function readJsonSafe(filePath, defaultValue = {}) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
return defaultValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonSafe(filePath, data) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error writing to ${filePath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Random helpers
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
// Date helpers
|
||||
function formatDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function subtractDays(date, days) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() - days);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Generate sample challenges
|
||||
function generateChallenges() {
|
||||
const challenges = {
|
||||
daily: [
|
||||
{
|
||||
id: "daily-practice-10",
|
||||
name: "Practice Makes Perfect",
|
||||
description: "Complete 10 practice words in a day",
|
||||
type: "daily",
|
||||
target: 10,
|
||||
points: 50,
|
||||
icon: "🎯"
|
||||
},
|
||||
{
|
||||
id: "daily-accuracy-90",
|
||||
name: "Sharp Shooter",
|
||||
description: "Achieve 90% accuracy in daily practice",
|
||||
type: "daily",
|
||||
target: 0.9,
|
||||
points: 75,
|
||||
icon: "🎯"
|
||||
},
|
||||
{
|
||||
id: "daily-streak-3",
|
||||
name: "On Fire",
|
||||
description: "Get 3 words correct in a row",
|
||||
type: "daily",
|
||||
target: 3,
|
||||
points: 30,
|
||||
icon: "🔥"
|
||||
}
|
||||
],
|
||||
weekly: [
|
||||
{
|
||||
id: "weekly-words-50",
|
||||
name: "Word Warrior",
|
||||
description: "Practice 50 words in a week",
|
||||
type: "weekly",
|
||||
target: 50,
|
||||
points: 200,
|
||||
icon: "⚔️"
|
||||
},
|
||||
{
|
||||
id: "weekly-days-5",
|
||||
name: "Dedicated Scholar",
|
||||
description: "Practice on 5 different days this week",
|
||||
type: "weekly",
|
||||
target: 5,
|
||||
points: 250,
|
||||
icon: "📚"
|
||||
}
|
||||
],
|
||||
achievements: [
|
||||
{
|
||||
id: "achievement-words-100",
|
||||
name: "Century Club",
|
||||
description: "Practice 100 total words",
|
||||
type: "achievement",
|
||||
target: 100,
|
||||
points: 100,
|
||||
icon: "🏆"
|
||||
},
|
||||
{
|
||||
id: "achievement-words-500",
|
||||
name: "Word Master",
|
||||
description: "Practice 500 total words",
|
||||
type: "achievement",
|
||||
target: 500,
|
||||
points: 500,
|
||||
icon: "👑"
|
||||
},
|
||||
{
|
||||
id: "achievement-streak-7",
|
||||
name: "Weekly Warrior",
|
||||
description: "Maintain a 7-day practice streak",
|
||||
type: "achievement",
|
||||
target: 7,
|
||||
points: 300,
|
||||
icon: "📅"
|
||||
},
|
||||
{
|
||||
id: "achievement-accuracy-95",
|
||||
name: "Spelling Bee Champion",
|
||||
description: "Maintain 95% accuracy across 100 words",
|
||||
type: "achievement",
|
||||
target: 0.95,
|
||||
points: 400,
|
||||
icon: "🐝"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return challenges;
|
||||
}
|
||||
|
||||
// Generate progress data for a user
|
||||
function generateUserProgress(username) {
|
||||
const today = new Date();
|
||||
|
||||
// Generate streak data
|
||||
const currentStreak = randomInt(1, 14);
|
||||
const longestStreak = Math.max(currentStreak, randomInt(7, 21));
|
||||
|
||||
// Generate points from challenges and achievements
|
||||
const totalPoints = randomInt(500, 2000);
|
||||
|
||||
// Generate completed challenges
|
||||
const dailyChallenges = [];
|
||||
const weeklyChallenges = [];
|
||||
const achievements = [];
|
||||
|
||||
// Generate some daily challenges for the past 14 days
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const date = formatDate(subtractDays(today, i));
|
||||
|
||||
// 70% chance to have completed some daily challenges
|
||||
if (Math.random() < 0.7) {
|
||||
// Random selection of daily challenges
|
||||
const dailyIds = ["daily-practice-10", "daily-accuracy-90", "daily-streak-3"];
|
||||
const completedCount = randomInt(1, dailyIds.length);
|
||||
|
||||
for (let j = 0; j < completedCount; j++) {
|
||||
dailyChallenges.push({
|
||||
id: dailyIds[j],
|
||||
date,
|
||||
progress: 1.0 // 100% complete
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate weekly challenges for the past 4 weeks
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const weekStart = formatDate(subtractDays(today, 7 * (i + 1)));
|
||||
|
||||
// 60% chance to have completed a weekly challenge
|
||||
if (Math.random() < 0.6) {
|
||||
const weeklyIds = ["weekly-words-50", "weekly-days-5"];
|
||||
const completedId = weeklyIds[randomInt(0, weeklyIds.length - 1)];
|
||||
|
||||
weeklyChallenges.push({
|
||||
id: completedId,
|
||||
weekStart,
|
||||
progress: 1.0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate achievements (randomly completed)
|
||||
const achievementIds = [
|
||||
"achievement-words-100",
|
||||
"achievement-words-500",
|
||||
"achievement-streak-7",
|
||||
"achievement-accuracy-95"
|
||||
];
|
||||
|
||||
// 30-70% of achievements completed
|
||||
const completedCount = randomInt(
|
||||
Math.floor(achievementIds.length * 0.3),
|
||||
Math.floor(achievementIds.length * 0.7)
|
||||
);
|
||||
|
||||
for (let i = 0; i < completedCount; i++) {
|
||||
const achievementId = achievementIds[i];
|
||||
const daysAgo = randomInt(1, 30);
|
||||
|
||||
achievements.push({
|
||||
id: achievementId,
|
||||
date: formatDate(subtractDays(today, daysAgo)),
|
||||
progress: 1.0
|
||||
});
|
||||
}
|
||||
|
||||
// Overall user progress data
|
||||
return {
|
||||
streak: {
|
||||
current: currentStreak,
|
||||
longest: longestStreak,
|
||||
lastPractice: formatDate(subtractDays(today, 1)) // Yesterday
|
||||
},
|
||||
points: totalPoints,
|
||||
level: Math.floor(totalPoints / 500) + 1, // Level up every 500 points
|
||||
dailyChallenges,
|
||||
weeklyChallenges,
|
||||
achievements,
|
||||
stats: {
|
||||
wordsAttempted: randomInt(100, 1000),
|
||||
wordsCorrect: randomInt(70, 95) / 100 * randomInt(100, 1000),
|
||||
sessionsCompleted: randomInt(20, 100),
|
||||
averageAccuracy: randomInt(70, 98) / 100
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Main function to generate gamification data
|
||||
async function generateGamificationData() {
|
||||
console.log('Generating sample gamification data...');
|
||||
|
||||
// Load users
|
||||
const users = readJsonSafe(files.users, []);
|
||||
|
||||
if (users.length === 0) {
|
||||
console.error('No users found. Please ensure users exist before running this script.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate challenges
|
||||
console.log('Generating challenges data...');
|
||||
const challenges = generateChallenges();
|
||||
|
||||
// Generate progress for each user
|
||||
const progress = {};
|
||||
|
||||
for (const user of users) {
|
||||
const username = user.username;
|
||||
console.log(`Generating progress data for user: ${username}`);
|
||||
progress[username] = generateUserProgress(username);
|
||||
}
|
||||
|
||||
// Save data
|
||||
console.log('Saving challenges and progress data...');
|
||||
writeJsonSafe(files.challenges, challenges);
|
||||
writeJsonSafe(files.progress, progress);
|
||||
|
||||
console.log('Gamification data generation complete!');
|
||||
}
|
||||
|
||||
// Execute the script
|
||||
generateGamificationData();
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* Generate sample leaderboard data for the Spelling Practice App
|
||||
* This script will populate leaderboards with realistic data for the analytics dashboard
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Define paths
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const files = {
|
||||
users: path.join(DATA_DIR, 'users.json'),
|
||||
progress: path.join(DATA_DIR, 'progress.json'),
|
||||
leaderboards: path.join(DATA_DIR, 'leaderboards.json')
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
function readJsonSafe(filePath, defaultValue = {}) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
return defaultValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonSafe(filePath, data) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error writing to ${filePath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Random helpers
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
function randomFloat(min, max, decimals = 2) {
|
||||
const rand = Math.random() * (max - min) + min;
|
||||
return parseFloat(rand.toFixed(decimals));
|
||||
}
|
||||
|
||||
// Generate additional fake users for the leaderboards
|
||||
function generateFakeUsers(count) {
|
||||
const firstNames = ['Emma', 'Liam', 'Olivia', 'Noah', 'Ava', 'William', 'Sophia', 'James', 'Isabella', 'Logan',
|
||||
'Mia', 'Benjamin', 'Charlotte', 'Mason', 'Amelia', 'Elijah', 'Harper', 'Oliver', 'Evelyn', 'Jacob'];
|
||||
|
||||
const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez',
|
||||
'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin'];
|
||||
|
||||
const users = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const firstName = firstNames[randomInt(0, firstNames.length - 1)];
|
||||
const lastName = lastNames[randomInt(0, lastNames.length - 1)];
|
||||
const username = `${firstName.toLowerCase()}${lastName.substring(0, 1).toLowerCase()}${randomInt(1, 99)}`;
|
||||
|
||||
users.push(username);
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
// Main function to generate leaderboard data
|
||||
async function generateLeaderboardData() {
|
||||
console.log('Generating sample leaderboard data...');
|
||||
|
||||
// Load users and progress data
|
||||
const users = readJsonSafe(files.users, []);
|
||||
const progressData = readJsonSafe(files.progress, {});
|
||||
|
||||
if (users.length === 0) {
|
||||
console.error('No users found. Please ensure users exist before running this script.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate 10 additional fake users for leaderboards
|
||||
const fakeUsers = generateFakeUsers(10);
|
||||
const allUsers = [...users.map(u => u.username), ...fakeUsers];
|
||||
|
||||
// Create leaderboard data
|
||||
const leaderboards = {
|
||||
allTime: {
|
||||
accuracy: [],
|
||||
totalWords: [],
|
||||
sessionsCompleted: [],
|
||||
streakDays: []
|
||||
},
|
||||
weekly: {
|
||||
accuracy: [],
|
||||
totalWords: [],
|
||||
sessionsCompleted: []
|
||||
},
|
||||
currentWeek: `${new Date().getFullYear()}-${Math.ceil((new Date().getDate() + new Date().getDay()) / 7)}`
|
||||
};
|
||||
|
||||
// Generate data for each category
|
||||
|
||||
// All-time accuracy leaderboard
|
||||
allUsers.forEach(username => {
|
||||
// For real users, use their actual progress data if available
|
||||
if (progressData[username]) {
|
||||
leaderboards.allTime.accuracy.push({
|
||||
username,
|
||||
value: progressData[username].stats.accuracy || randomFloat(70, 98, 1),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
// For fake users, generate random data
|
||||
leaderboards.allTime.accuracy.push({
|
||||
username,
|
||||
value: randomFloat(70, 98, 1),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by value (descending)
|
||||
leaderboards.allTime.accuracy.sort((a, b) => b.value - a.value);
|
||||
|
||||
// All-time totalWords leaderboard
|
||||
allUsers.forEach(username => {
|
||||
if (progressData[username]) {
|
||||
leaderboards.allTime.totalWords.push({
|
||||
username,
|
||||
value: progressData[username].stats.totalWords || randomInt(100, 1000),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
leaderboards.allTime.totalWords.push({
|
||||
username,
|
||||
value: randomInt(100, 1000),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
leaderboards.allTime.totalWords.sort((a, b) => b.value - a.value);
|
||||
|
||||
// All-time sessionsCompleted leaderboard
|
||||
allUsers.forEach(username => {
|
||||
if (progressData[username]) {
|
||||
leaderboards.allTime.sessionsCompleted.push({
|
||||
username,
|
||||
value: progressData[username].stats.totalSessions || randomInt(5, 50),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
leaderboards.allTime.sessionsCompleted.push({
|
||||
username,
|
||||
value: randomInt(5, 50),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
leaderboards.allTime.sessionsCompleted.sort((a, b) => b.value - a.value);
|
||||
|
||||
// All-time streakDays leaderboard
|
||||
allUsers.forEach(username => {
|
||||
if (progressData[username]) {
|
||||
leaderboards.allTime.streakDays.push({
|
||||
username,
|
||||
value: progressData[username].streaks?.longest || randomInt(1, 14),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
leaderboards.allTime.streakDays.push({
|
||||
username,
|
||||
value: randomInt(1, 14),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
leaderboards.allTime.streakDays.sort((a, b) => b.value - a.value);
|
||||
|
||||
// Generate weekly leaderboards
|
||||
// Copy from all-time but with different values
|
||||
|
||||
// Weekly accuracy
|
||||
allUsers.forEach(username => {
|
||||
const allTimeEntry = leaderboards.allTime.accuracy.find(entry => entry.username === username);
|
||||
const baseValue = allTimeEntry ? allTimeEntry.value : randomFloat(70, 98, 1);
|
||||
|
||||
// Weekly value is slightly different from all-time
|
||||
leaderboards.weekly.accuracy.push({
|
||||
username,
|
||||
value: Math.min(99.9, Math.max(50, baseValue + randomFloat(-5, 5, 1))),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
leaderboards.weekly.accuracy.sort((a, b) => b.value - a.value);
|
||||
|
||||
// Weekly totalWords
|
||||
allUsers.forEach(username => {
|
||||
const allTimeEntry = leaderboards.allTime.totalWords.find(entry => entry.username === username);
|
||||
const baseValue = allTimeEntry ? allTimeEntry.value : randomInt(100, 1000);
|
||||
|
||||
// Weekly value is much smaller than all-time
|
||||
leaderboards.weekly.totalWords.push({
|
||||
username,
|
||||
value: randomInt(10, Math.min(100, Math.floor(baseValue / 10))),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
leaderboards.weekly.totalWords.sort((a, b) => b.value - a.value);
|
||||
|
||||
// Weekly sessionsCompleted
|
||||
allUsers.forEach(username => {
|
||||
const allTimeEntry = leaderboards.allTime.sessionsCompleted.find(entry => entry.username === username);
|
||||
const baseValue = allTimeEntry ? allTimeEntry.value : randomInt(5, 50);
|
||||
|
||||
// Weekly value is much smaller than all-time
|
||||
leaderboards.weekly.sessionsCompleted.push({
|
||||
username,
|
||||
value: randomInt(1, Math.min(7, Math.floor(baseValue / 5))),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
leaderboards.weekly.sessionsCompleted.sort((a, b) => b.value - a.value);
|
||||
|
||||
// Save the leaderboard data
|
||||
console.log('Saving leaderboard data...');
|
||||
writeJsonSafe(files.leaderboards, leaderboards);
|
||||
|
||||
console.log('Leaderboard data generation complete!');
|
||||
}
|
||||
|
||||
// Execute the script
|
||||
generateLeaderboardData();
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* Generate sample spaced repetition data for the Spelling Practice App
|
||||
* This script will populate the spaced repetition system with realistic learning data
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Define paths
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const files = {
|
||||
users: path.join(DATA_DIR, 'users.json'),
|
||||
wordlists: path.join(DATA_DIR, 'wordlists.json'),
|
||||
spacedRepetition: path.join(DATA_DIR, 'spacedRepetition.json')
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
function readJsonSafe(filePath, defaultValue = {}) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
return defaultValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonSafe(filePath, data) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error writing to ${filePath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Random helpers
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
function randomFloat(min, max, decimals = 2) {
|
||||
const rand = Math.random() * (max - min) + min;
|
||||
return parseFloat(rand.toFixed(decimals));
|
||||
}
|
||||
|
||||
// Date helpers
|
||||
function addDays(date, days) {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
function subtractDays(date, days) {
|
||||
return addDays(date, -days);
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Main function to generate spaced repetition data
|
||||
async function generateSpacedRepetitionData() {
|
||||
console.log('Generating sample spaced repetition data...');
|
||||
|
||||
// Load users and wordlists
|
||||
const users = readJsonSafe(files.users, []);
|
||||
const wordlists = readJsonSafe(files.wordlists, {});
|
||||
|
||||
if (users.length === 0) {
|
||||
console.error('No users found. Please ensure users exist before running this script.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create spaced repetition data structure
|
||||
const srData = readJsonSafe(files.spacedRepetition, {});
|
||||
|
||||
// Generate data for each user
|
||||
const today = new Date();
|
||||
|
||||
for (const user of users) {
|
||||
const username = user.username;
|
||||
console.log(`Generating spaced repetition data for user: ${username}`);
|
||||
|
||||
// Get user's words
|
||||
let userWords = [];
|
||||
|
||||
// Handle different wordlist structures
|
||||
if (Array.isArray(wordlists[username])) {
|
||||
userWords = wordlists[username];
|
||||
} else if (wordlists[username] && wordlists[username].weeks && Array.isArray(wordlists[username].weeks)) {
|
||||
// Extract all words from all weeks
|
||||
wordlists[username].weeks.forEach(week => {
|
||||
if (week.words && Array.isArray(week.words)) {
|
||||
userWords = [...userWords, ...week.words];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (userWords.length === 0) {
|
||||
console.log(`No words found for user ${username}. Skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Initialize user's spaced repetition data if not exists
|
||||
if (!srData[username]) {
|
||||
srData[username] = {
|
||||
settings: {
|
||||
dailyLimit: 20,
|
||||
reviewThreshold: 3,
|
||||
newWordsPerDay: 5
|
||||
},
|
||||
stats: {
|
||||
totalReviews: 0,
|
||||
correctReviews: 0,
|
||||
totalWords: 0,
|
||||
masteredWords: 0
|
||||
},
|
||||
words: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Add words with different mastery levels
|
||||
let totalReviews = 0;
|
||||
let correctReviews = 0;
|
||||
let masteredWords = 0;
|
||||
|
||||
for (const word of userWords) {
|
||||
// Determine mastery level
|
||||
// 0: New - 20%
|
||||
// 1-2: Learning - 30%
|
||||
// 3-4: Reviewing - 30%
|
||||
// 5+: Mastered - 20%
|
||||
|
||||
const rand = Math.random();
|
||||
let repetitions = 0;
|
||||
let interval = 0;
|
||||
let easeFactor = 2.5;
|
||||
|
||||
if (rand < 0.2) {
|
||||
// New word
|
||||
repetitions = 0;
|
||||
interval = 0;
|
||||
easeFactor = randomFloat(2.3, 2.7, 1);
|
||||
} else if (rand < 0.5) {
|
||||
// Learning
|
||||
repetitions = randomInt(1, 2);
|
||||
interval = repetitions === 1 ? 1 : 3;
|
||||
easeFactor = randomFloat(2.0, 2.5, 1);
|
||||
totalReviews += repetitions;
|
||||
correctReviews += repetitions - 1;
|
||||
} else if (rand < 0.8) {
|
||||
// Reviewing
|
||||
repetitions = randomInt(3, 4);
|
||||
interval = repetitions === 3 ? 7 : 15;
|
||||
easeFactor = randomFloat(2.2, 2.8, 1);
|
||||
totalReviews += repetitions;
|
||||
correctReviews += repetitions - 1;
|
||||
} else {
|
||||
// Mastered
|
||||
repetitions = randomInt(5, 8);
|
||||
interval = Math.pow(2, repetitions - 4) * 10; // Exponential increase
|
||||
easeFactor = randomFloat(2.6, 3.0, 1);
|
||||
totalReviews += repetitions;
|
||||
correctReviews += repetitions - 1;
|
||||
masteredWords++;
|
||||
}
|
||||
|
||||
// Calculate last and next review dates
|
||||
let nextReview = null;
|
||||
let lastReview = null;
|
||||
|
||||
if (repetitions > 0) {
|
||||
// If the word has been reviewed before, set lastReview
|
||||
lastReview = formatDate(subtractDays(today, randomInt(1, interval)));
|
||||
|
||||
// Next review might be due
|
||||
const dueChance = 0.3; // 30% chance word is due
|
||||
if (Math.random() < dueChance) {
|
||||
// Word is due today or in the past
|
||||
nextReview = formatDate(subtractDays(today, randomInt(0, 3)));
|
||||
} else {
|
||||
// Word is due in the future
|
||||
nextReview = formatDate(addDays(today, randomInt(1, interval)));
|
||||
}
|
||||
} else {
|
||||
// New word, not reviewed yet
|
||||
nextReview = formatDate(today);
|
||||
}
|
||||
|
||||
// Add word to user's SR data
|
||||
srData[username].words[word] = {
|
||||
easeFactor,
|
||||
interval,
|
||||
repetitions,
|
||||
lastReview,
|
||||
nextReview
|
||||
};
|
||||
}
|
||||
|
||||
// Update user stats
|
||||
srData[username].stats = {
|
||||
totalReviews,
|
||||
correctReviews,
|
||||
totalWords: userWords.length,
|
||||
masteredWords
|
||||
};
|
||||
}
|
||||
|
||||
// Save spaced repetition data
|
||||
console.log('Saving spaced repetition data...');
|
||||
writeJsonSafe(files.spacedRepetition, srData);
|
||||
|
||||
console.log('Spaced repetition data generation complete!');
|
||||
}
|
||||
|
||||
// Execute the script
|
||||
generateSpacedRepetitionData();
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
// Health check script for the Spelling Practice app
|
||||
// Run this to verify all the fixes are working properly
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
|
||||
console.log('🔍 Running health check...');
|
||||
|
||||
// Define directories and files
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const SEED_DIR = path.join(__dirname, 'seed');
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||
const JS_DIR = path.join(PUBLIC_DIR, 'js');
|
||||
|
||||
const files = {
|
||||
users: path.join(DATA_DIR, 'users.json'),
|
||||
wordlists: path.join(DATA_DIR, 'wordlists.json'),
|
||||
results: path.join(DATA_DIR, 'results.json'),
|
||||
badges: path.join(DATA_DIR, 'badges.json'),
|
||||
leaderboards: path.join(DATA_DIR, 'leaderboards.json'),
|
||||
challenges: path.join(DATA_DIR, 'challenges.json'),
|
||||
progress: path.join(DATA_DIR, 'progress.json'),
|
||||
spacedRepetition: path.join(DATA_DIR, 'spacedRepetition.json')
|
||||
};
|
||||
|
||||
// Check directories
|
||||
console.log('📁 Checking directories...');
|
||||
[DATA_DIR, PUBLIC_DIR, JS_DIR].forEach(dir => {
|
||||
if (fs.existsSync(dir)) {
|
||||
console.log(`✅ ${path.basename(dir)} directory exists`);
|
||||
} else {
|
||||
console.error(`❌ ${path.basename(dir)} directory is missing!`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check required data files
|
||||
console.log('\n🗃️ Checking data files...');
|
||||
Object.entries(files).forEach(([name, filePath]) => {
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
if (name === 'users') {
|
||||
console.log(`✅ ${name}: Found ${data.length} users: ${data.map(u => u.username).join(', ')}`);
|
||||
} else {
|
||||
console.log(`✅ ${name}: Valid JSON (${content.length} bytes)`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`❌ ${name}: Invalid JSON - ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
console.error(`❌ ${name}: File not found!`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check critical JS files
|
||||
console.log('\n📜 Checking JS files...');
|
||||
const jsFiles = [
|
||||
'admin.js', 'analytics.js', 'auth.js', 'game.js', 'main.js',
|
||||
'ui.js', 'init.js', 'debug-analytics.js'
|
||||
];
|
||||
|
||||
jsFiles.forEach(file => {
|
||||
const filePath = path.join(JS_DIR, file);
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
console.log(`✅ ${file}: ${content.length} bytes`);
|
||||
|
||||
// Look for specific fixed patterns
|
||||
if (file === 'admin.js') {
|
||||
if (content.includes('window.adminUsers')) {
|
||||
console.log(` ✅ admin.js: Global adminUsers variable found`);
|
||||
} else {
|
||||
console.warn(` ⚠️ admin.js: Global adminUsers variable not found`);
|
||||
}
|
||||
}
|
||||
|
||||
if (file === 'analytics.js') {
|
||||
if (content.includes('window.analyticsRefresh')) {
|
||||
console.log(` ✅ analytics.js: Global analyticsRefresh function found`);
|
||||
} else {
|
||||
console.warn(` ⚠️ analytics.js: Global analyticsRefresh function not found`);
|
||||
}
|
||||
}
|
||||
|
||||
if (file === 'ui.js') {
|
||||
if (content.includes('window.switchTab')) {
|
||||
console.log(` ✅ ui.js: Global switchTab function found`);
|
||||
} else {
|
||||
console.warn(` ⚠️ ui.js: Global switchTab function not found`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(`❌ ${file}: File not found!`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check package.json
|
||||
console.log('\n📦 Checking package.json...');
|
||||
const pkgPath = path.join(__dirname, 'package.json');
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
||||
console.log(`✅ Package name: ${pkg.name}`);
|
||||
console.log(`✅ Version: ${pkg.version}`);
|
||||
console.log(`✅ Dependencies: ${Object.keys(pkg.dependencies || {}).length}`);
|
||||
} catch (e) {
|
||||
console.error(`❌ Invalid package.json: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
console.error('❌ package.json not found!');
|
||||
}
|
||||
|
||||
console.log('\n🏁 Health check complete!');
|
||||
console.log('To test the application, run node server.js and open http://localhost:3000');
|
||||
-40
@@ -335,12 +335,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs": {
|
||||
"version": "0.0.1-security",
|
||||
"resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz",
|
||||
"integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -627,16 +621,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path": {
|
||||
"version": "0.12.7",
|
||||
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
|
||||
"integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"process": "^0.11.1",
|
||||
"util": "^0.10.3"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
||||
@@ -646,15 +630,6 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -905,21 +880,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
|
||||
"integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/util/node_modules/inherits": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
# Security holding package
|
||||
|
||||
This package name is not currently in use, but was formerly occupied
|
||||
by another package. To avoid malicious use, npm is hanging on to the
|
||||
package name, but loosely, and we'll probably give it to you if you
|
||||
want it.
|
||||
|
||||
You may adopt this package by contacting support@npmjs.com and
|
||||
requesting the name.
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "fs",
|
||||
"version": "0.0.1-security",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/npm/security-holder.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/npm/security-holder/issues"
|
||||
},
|
||||
"homepage": "https://github.com/npm/security-holder#readme"
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
node_modules
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to
|
||||
deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
# path
|
||||
|
||||
This is an exact copy of the NodeJS ’path’ module published to the NPM registry.
|
||||
|
||||
[Documentation](http://nodejs.org/docs/latest/api/path.html)
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
$ npm install --save path
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"author": {
|
||||
"name": "Joyent",
|
||||
"url": "http://www.joyent.com"
|
||||
},
|
||||
"name": "path",
|
||||
"description": "Node.JS path module",
|
||||
"keywords": [
|
||||
"ender",
|
||||
"path"
|
||||
],
|
||||
"license": "MIT",
|
||||
"version": "0.12.7",
|
||||
"homepage": "http://nodejs.org/docs/latest/api/path.html",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/jinder/path.git"
|
||||
},
|
||||
"main": "./path.js",
|
||||
"dependencies": {
|
||||
"process": "^0.11.1",
|
||||
"util": "^0.10.3"
|
||||
}
|
||||
}
|
||||
-628
@@ -1,628 +0,0 @@
|
||||
// Copyright Joyent, Inc. and other Node contributors.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
// copy of this software and associated documentation files (the
|
||||
// "Software"), to deal in the Software without restriction, including
|
||||
// without limitation the rights to use, copy, modify, merge, publish,
|
||||
// distribute, sublicense, and/or sell copies of the Software, and to permit
|
||||
// persons to whom the Software is furnished to do so, subject to the
|
||||
// following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included
|
||||
// in all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
||||
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
// USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
'use strict';
|
||||
|
||||
|
||||
var isWindows = process.platform === 'win32';
|
||||
var util = require('util');
|
||||
|
||||
|
||||
// resolves . and .. elements in a path array with directory names there
|
||||
// must be no slashes or device names (c:\) in the array
|
||||
// (so also no leading and trailing slashes - it does not distinguish
|
||||
// relative and absolute paths)
|
||||
function normalizeArray(parts, allowAboveRoot) {
|
||||
var res = [];
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var p = parts[i];
|
||||
|
||||
// ignore empty parts
|
||||
if (!p || p === '.')
|
||||
continue;
|
||||
|
||||
if (p === '..') {
|
||||
if (res.length && res[res.length - 1] !== '..') {
|
||||
res.pop();
|
||||
} else if (allowAboveRoot) {
|
||||
res.push('..');
|
||||
}
|
||||
} else {
|
||||
res.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// returns an array with empty elements removed from either end of the input
|
||||
// array or the original array if no elements need to be removed
|
||||
function trimArray(arr) {
|
||||
var lastIndex = arr.length - 1;
|
||||
var start = 0;
|
||||
for (; start <= lastIndex; start++) {
|
||||
if (arr[start])
|
||||
break;
|
||||
}
|
||||
|
||||
var end = lastIndex;
|
||||
for (; end >= 0; end--) {
|
||||
if (arr[end])
|
||||
break;
|
||||
}
|
||||
|
||||
if (start === 0 && end === lastIndex)
|
||||
return arr;
|
||||
if (start > end)
|
||||
return [];
|
||||
return arr.slice(start, end + 1);
|
||||
}
|
||||
|
||||
// Regex to split a windows path into three parts: [*, device, slash,
|
||||
// tail] windows-only
|
||||
var splitDeviceRe =
|
||||
/^([a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/]+[^\\\/]+)?([\\\/])?([\s\S]*?)$/;
|
||||
|
||||
// Regex to split the tail part of the above into [*, dir, basename, ext]
|
||||
var splitTailRe =
|
||||
/^([\s\S]*?)((?:\.{1,2}|[^\\\/]+?|)(\.[^.\/\\]*|))(?:[\\\/]*)$/;
|
||||
|
||||
var win32 = {};
|
||||
|
||||
// Function to split a filename into [root, dir, basename, ext]
|
||||
function win32SplitPath(filename) {
|
||||
// Separate device+slash from tail
|
||||
var result = splitDeviceRe.exec(filename),
|
||||
device = (result[1] || '') + (result[2] || ''),
|
||||
tail = result[3] || '';
|
||||
// Split the tail into dir, basename and extension
|
||||
var result2 = splitTailRe.exec(tail),
|
||||
dir = result2[1],
|
||||
basename = result2[2],
|
||||
ext = result2[3];
|
||||
return [device, dir, basename, ext];
|
||||
}
|
||||
|
||||
function win32StatPath(path) {
|
||||
var result = splitDeviceRe.exec(path),
|
||||
device = result[1] || '',
|
||||
isUnc = !!device && device[1] !== ':';
|
||||
return {
|
||||
device: device,
|
||||
isUnc: isUnc,
|
||||
isAbsolute: isUnc || !!result[2], // UNC paths are always absolute
|
||||
tail: result[3]
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUNCRoot(device) {
|
||||
return '\\\\' + device.replace(/^[\\\/]+/, '').replace(/[\\\/]+/g, '\\');
|
||||
}
|
||||
|
||||
// path.resolve([from ...], to)
|
||||
win32.resolve = function() {
|
||||
var resolvedDevice = '',
|
||||
resolvedTail = '',
|
||||
resolvedAbsolute = false;
|
||||
|
||||
for (var i = arguments.length - 1; i >= -1; i--) {
|
||||
var path;
|
||||
if (i >= 0) {
|
||||
path = arguments[i];
|
||||
} else if (!resolvedDevice) {
|
||||
path = process.cwd();
|
||||
} else {
|
||||
// Windows has the concept of drive-specific current working
|
||||
// directories. If we've resolved a drive letter but not yet an
|
||||
// absolute path, get cwd for that drive. We're sure the device is not
|
||||
// an unc path at this points, because unc paths are always absolute.
|
||||
path = process.env['=' + resolvedDevice];
|
||||
// Verify that a drive-local cwd was found and that it actually points
|
||||
// to our drive. If not, default to the drive's root.
|
||||
if (!path || path.substr(0, 3).toLowerCase() !==
|
||||
resolvedDevice.toLowerCase() + '\\') {
|
||||
path = resolvedDevice + '\\';
|
||||
}
|
||||
}
|
||||
|
||||
// Skip empty and invalid entries
|
||||
if (!util.isString(path)) {
|
||||
throw new TypeError('Arguments to path.resolve must be strings');
|
||||
} else if (!path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var result = win32StatPath(path),
|
||||
device = result.device,
|
||||
isUnc = result.isUnc,
|
||||
isAbsolute = result.isAbsolute,
|
||||
tail = result.tail;
|
||||
|
||||
if (device &&
|
||||
resolvedDevice &&
|
||||
device.toLowerCase() !== resolvedDevice.toLowerCase()) {
|
||||
// This path points to another device so it is not applicable
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!resolvedDevice) {
|
||||
resolvedDevice = device;
|
||||
}
|
||||
if (!resolvedAbsolute) {
|
||||
resolvedTail = tail + '\\' + resolvedTail;
|
||||
resolvedAbsolute = isAbsolute;
|
||||
}
|
||||
|
||||
if (resolvedDevice && resolvedAbsolute) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert slashes to backslashes when `resolvedDevice` points to an UNC
|
||||
// root. Also squash multiple slashes into a single one where appropriate.
|
||||
if (isUnc) {
|
||||
resolvedDevice = normalizeUNCRoot(resolvedDevice);
|
||||
}
|
||||
|
||||
// At this point the path should be resolved to a full absolute path,
|
||||
// but handle relative paths to be safe (might happen when process.cwd()
|
||||
// fails)
|
||||
|
||||
// Normalize the tail path
|
||||
resolvedTail = normalizeArray(resolvedTail.split(/[\\\/]+/),
|
||||
!resolvedAbsolute).join('\\');
|
||||
|
||||
return (resolvedDevice + (resolvedAbsolute ? '\\' : '') + resolvedTail) ||
|
||||
'.';
|
||||
};
|
||||
|
||||
|
||||
win32.normalize = function(path) {
|
||||
var result = win32StatPath(path),
|
||||
device = result.device,
|
||||
isUnc = result.isUnc,
|
||||
isAbsolute = result.isAbsolute,
|
||||
tail = result.tail,
|
||||
trailingSlash = /[\\\/]$/.test(tail);
|
||||
|
||||
// Normalize the tail path
|
||||
tail = normalizeArray(tail.split(/[\\\/]+/), !isAbsolute).join('\\');
|
||||
|
||||
if (!tail && !isAbsolute) {
|
||||
tail = '.';
|
||||
}
|
||||
if (tail && trailingSlash) {
|
||||
tail += '\\';
|
||||
}
|
||||
|
||||
// Convert slashes to backslashes when `device` points to an UNC root.
|
||||
// Also squash multiple slashes into a single one where appropriate.
|
||||
if (isUnc) {
|
||||
device = normalizeUNCRoot(device);
|
||||
}
|
||||
|
||||
return device + (isAbsolute ? '\\' : '') + tail;
|
||||
};
|
||||
|
||||
|
||||
win32.isAbsolute = function(path) {
|
||||
return win32StatPath(path).isAbsolute;
|
||||
};
|
||||
|
||||
win32.join = function() {
|
||||
var paths = [];
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
var arg = arguments[i];
|
||||
if (!util.isString(arg)) {
|
||||
throw new TypeError('Arguments to path.join must be strings');
|
||||
}
|
||||
if (arg) {
|
||||
paths.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
var joined = paths.join('\\');
|
||||
|
||||
// Make sure that the joined path doesn't start with two slashes, because
|
||||
// normalize() will mistake it for an UNC path then.
|
||||
//
|
||||
// This step is skipped when it is very clear that the user actually
|
||||
// intended to point at an UNC path. This is assumed when the first
|
||||
// non-empty string arguments starts with exactly two slashes followed by
|
||||
// at least one more non-slash character.
|
||||
//
|
||||
// Note that for normalize() to treat a path as an UNC path it needs to
|
||||
// have at least 2 components, so we don't filter for that here.
|
||||
// This means that the user can use join to construct UNC paths from
|
||||
// a server name and a share name; for example:
|
||||
// path.join('//server', 'share') -> '\\\\server\\share\')
|
||||
if (!/^[\\\/]{2}[^\\\/]/.test(paths[0])) {
|
||||
joined = joined.replace(/^[\\\/]{2,}/, '\\');
|
||||
}
|
||||
|
||||
return win32.normalize(joined);
|
||||
};
|
||||
|
||||
|
||||
// path.relative(from, to)
|
||||
// it will solve the relative path from 'from' to 'to', for instance:
|
||||
// from = 'C:\\orandea\\test\\aaa'
|
||||
// to = 'C:\\orandea\\impl\\bbb'
|
||||
// The output of the function should be: '..\\..\\impl\\bbb'
|
||||
win32.relative = function(from, to) {
|
||||
from = win32.resolve(from);
|
||||
to = win32.resolve(to);
|
||||
|
||||
// windows is not case sensitive
|
||||
var lowerFrom = from.toLowerCase();
|
||||
var lowerTo = to.toLowerCase();
|
||||
|
||||
var toParts = trimArray(to.split('\\'));
|
||||
|
||||
var lowerFromParts = trimArray(lowerFrom.split('\\'));
|
||||
var lowerToParts = trimArray(lowerTo.split('\\'));
|
||||
|
||||
var length = Math.min(lowerFromParts.length, lowerToParts.length);
|
||||
var samePartsLength = length;
|
||||
for (var i = 0; i < length; i++) {
|
||||
if (lowerFromParts[i] !== lowerToParts[i]) {
|
||||
samePartsLength = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (samePartsLength == 0) {
|
||||
return to;
|
||||
}
|
||||
|
||||
var outputParts = [];
|
||||
for (var i = samePartsLength; i < lowerFromParts.length; i++) {
|
||||
outputParts.push('..');
|
||||
}
|
||||
|
||||
outputParts = outputParts.concat(toParts.slice(samePartsLength));
|
||||
|
||||
return outputParts.join('\\');
|
||||
};
|
||||
|
||||
|
||||
win32._makeLong = function(path) {
|
||||
// Note: this will *probably* throw somewhere.
|
||||
if (!util.isString(path))
|
||||
return path;
|
||||
|
||||
if (!path) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var resolvedPath = win32.resolve(path);
|
||||
|
||||
if (/^[a-zA-Z]\:\\/.test(resolvedPath)) {
|
||||
// path is local filesystem path, which needs to be converted
|
||||
// to long UNC path.
|
||||
return '\\\\?\\' + resolvedPath;
|
||||
} else if (/^\\\\[^?.]/.test(resolvedPath)) {
|
||||
// path is network UNC path, which needs to be converted
|
||||
// to long UNC path.
|
||||
return '\\\\?\\UNC\\' + resolvedPath.substring(2);
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
|
||||
win32.dirname = function(path) {
|
||||
var result = win32SplitPath(path),
|
||||
root = result[0],
|
||||
dir = result[1];
|
||||
|
||||
if (!root && !dir) {
|
||||
// No dirname whatsoever
|
||||
return '.';
|
||||
}
|
||||
|
||||
if (dir) {
|
||||
// It has a dirname, strip trailing slash
|
||||
dir = dir.substr(0, dir.length - 1);
|
||||
}
|
||||
|
||||
return root + dir;
|
||||
};
|
||||
|
||||
|
||||
win32.basename = function(path, ext) {
|
||||
var f = win32SplitPath(path)[2];
|
||||
// TODO: make this comparison case-insensitive on windows?
|
||||
if (ext && f.substr(-1 * ext.length) === ext) {
|
||||
f = f.substr(0, f.length - ext.length);
|
||||
}
|
||||
return f;
|
||||
};
|
||||
|
||||
|
||||
win32.extname = function(path) {
|
||||
return win32SplitPath(path)[3];
|
||||
};
|
||||
|
||||
|
||||
win32.format = function(pathObject) {
|
||||
if (!util.isObject(pathObject)) {
|
||||
throw new TypeError(
|
||||
"Parameter 'pathObject' must be an object, not " + typeof pathObject
|
||||
);
|
||||
}
|
||||
|
||||
var root = pathObject.root || '';
|
||||
|
||||
if (!util.isString(root)) {
|
||||
throw new TypeError(
|
||||
"'pathObject.root' must be a string or undefined, not " +
|
||||
typeof pathObject.root
|
||||
);
|
||||
}
|
||||
|
||||
var dir = pathObject.dir;
|
||||
var base = pathObject.base || '';
|
||||
if (!dir) {
|
||||
return base;
|
||||
}
|
||||
if (dir[dir.length - 1] === win32.sep) {
|
||||
return dir + base;
|
||||
}
|
||||
return dir + win32.sep + base;
|
||||
};
|
||||
|
||||
|
||||
win32.parse = function(pathString) {
|
||||
if (!util.isString(pathString)) {
|
||||
throw new TypeError(
|
||||
"Parameter 'pathString' must be a string, not " + typeof pathString
|
||||
);
|
||||
}
|
||||
var allParts = win32SplitPath(pathString);
|
||||
if (!allParts || allParts.length !== 4) {
|
||||
throw new TypeError("Invalid path '" + pathString + "'");
|
||||
}
|
||||
return {
|
||||
root: allParts[0],
|
||||
dir: allParts[0] + allParts[1].slice(0, -1),
|
||||
base: allParts[2],
|
||||
ext: allParts[3],
|
||||
name: allParts[2].slice(0, allParts[2].length - allParts[3].length)
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
win32.sep = '\\';
|
||||
win32.delimiter = ';';
|
||||
|
||||
|
||||
// Split a filename into [root, dir, basename, ext], unix version
|
||||
// 'root' is just a slash, or nothing.
|
||||
var splitPathRe =
|
||||
/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;
|
||||
var posix = {};
|
||||
|
||||
|
||||
function posixSplitPath(filename) {
|
||||
return splitPathRe.exec(filename).slice(1);
|
||||
}
|
||||
|
||||
|
||||
// path.resolve([from ...], to)
|
||||
// posix version
|
||||
posix.resolve = function() {
|
||||
var resolvedPath = '',
|
||||
resolvedAbsolute = false;
|
||||
|
||||
for (var i = arguments.length - 1; i >= -1 && !resolvedAbsolute; i--) {
|
||||
var path = (i >= 0) ? arguments[i] : process.cwd();
|
||||
|
||||
// Skip empty and invalid entries
|
||||
if (!util.isString(path)) {
|
||||
throw new TypeError('Arguments to path.resolve must be strings');
|
||||
} else if (!path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resolvedPath = path + '/' + resolvedPath;
|
||||
resolvedAbsolute = path[0] === '/';
|
||||
}
|
||||
|
||||
// At this point the path should be resolved to a full absolute path, but
|
||||
// handle relative paths to be safe (might happen when process.cwd() fails)
|
||||
|
||||
// Normalize the path
|
||||
resolvedPath = normalizeArray(resolvedPath.split('/'),
|
||||
!resolvedAbsolute).join('/');
|
||||
|
||||
return ((resolvedAbsolute ? '/' : '') + resolvedPath) || '.';
|
||||
};
|
||||
|
||||
// path.normalize(path)
|
||||
// posix version
|
||||
posix.normalize = function(path) {
|
||||
var isAbsolute = posix.isAbsolute(path),
|
||||
trailingSlash = path && path[path.length - 1] === '/';
|
||||
|
||||
// Normalize the path
|
||||
path = normalizeArray(path.split('/'), !isAbsolute).join('/');
|
||||
|
||||
if (!path && !isAbsolute) {
|
||||
path = '.';
|
||||
}
|
||||
if (path && trailingSlash) {
|
||||
path += '/';
|
||||
}
|
||||
|
||||
return (isAbsolute ? '/' : '') + path;
|
||||
};
|
||||
|
||||
// posix version
|
||||
posix.isAbsolute = function(path) {
|
||||
return path.charAt(0) === '/';
|
||||
};
|
||||
|
||||
// posix version
|
||||
posix.join = function() {
|
||||
var path = '';
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
var segment = arguments[i];
|
||||
if (!util.isString(segment)) {
|
||||
throw new TypeError('Arguments to path.join must be strings');
|
||||
}
|
||||
if (segment) {
|
||||
if (!path) {
|
||||
path += segment;
|
||||
} else {
|
||||
path += '/' + segment;
|
||||
}
|
||||
}
|
||||
}
|
||||
return posix.normalize(path);
|
||||
};
|
||||
|
||||
|
||||
// path.relative(from, to)
|
||||
// posix version
|
||||
posix.relative = function(from, to) {
|
||||
from = posix.resolve(from).substr(1);
|
||||
to = posix.resolve(to).substr(1);
|
||||
|
||||
var fromParts = trimArray(from.split('/'));
|
||||
var toParts = trimArray(to.split('/'));
|
||||
|
||||
var length = Math.min(fromParts.length, toParts.length);
|
||||
var samePartsLength = length;
|
||||
for (var i = 0; i < length; i++) {
|
||||
if (fromParts[i] !== toParts[i]) {
|
||||
samePartsLength = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var outputParts = [];
|
||||
for (var i = samePartsLength; i < fromParts.length; i++) {
|
||||
outputParts.push('..');
|
||||
}
|
||||
|
||||
outputParts = outputParts.concat(toParts.slice(samePartsLength));
|
||||
|
||||
return outputParts.join('/');
|
||||
};
|
||||
|
||||
|
||||
posix._makeLong = function(path) {
|
||||
return path;
|
||||
};
|
||||
|
||||
|
||||
posix.dirname = function(path) {
|
||||
var result = posixSplitPath(path),
|
||||
root = result[0],
|
||||
dir = result[1];
|
||||
|
||||
if (!root && !dir) {
|
||||
// No dirname whatsoever
|
||||
return '.';
|
||||
}
|
||||
|
||||
if (dir) {
|
||||
// It has a dirname, strip trailing slash
|
||||
dir = dir.substr(0, dir.length - 1);
|
||||
}
|
||||
|
||||
return root + dir;
|
||||
};
|
||||
|
||||
|
||||
posix.basename = function(path, ext) {
|
||||
var f = posixSplitPath(path)[2];
|
||||
// TODO: make this comparison case-insensitive on windows?
|
||||
if (ext && f.substr(-1 * ext.length) === ext) {
|
||||
f = f.substr(0, f.length - ext.length);
|
||||
}
|
||||
return f;
|
||||
};
|
||||
|
||||
|
||||
posix.extname = function(path) {
|
||||
return posixSplitPath(path)[3];
|
||||
};
|
||||
|
||||
|
||||
posix.format = function(pathObject) {
|
||||
if (!util.isObject(pathObject)) {
|
||||
throw new TypeError(
|
||||
"Parameter 'pathObject' must be an object, not " + typeof pathObject
|
||||
);
|
||||
}
|
||||
|
||||
var root = pathObject.root || '';
|
||||
|
||||
if (!util.isString(root)) {
|
||||
throw new TypeError(
|
||||
"'pathObject.root' must be a string or undefined, not " +
|
||||
typeof pathObject.root
|
||||
);
|
||||
}
|
||||
|
||||
var dir = pathObject.dir ? pathObject.dir + posix.sep : '';
|
||||
var base = pathObject.base || '';
|
||||
return dir + base;
|
||||
};
|
||||
|
||||
|
||||
posix.parse = function(pathString) {
|
||||
if (!util.isString(pathString)) {
|
||||
throw new TypeError(
|
||||
"Parameter 'pathString' must be a string, not " + typeof pathString
|
||||
);
|
||||
}
|
||||
var allParts = posixSplitPath(pathString);
|
||||
if (!allParts || allParts.length !== 4) {
|
||||
throw new TypeError("Invalid path '" + pathString + "'");
|
||||
}
|
||||
allParts[1] = allParts[1] || '';
|
||||
allParts[2] = allParts[2] || '';
|
||||
allParts[3] = allParts[3] || '';
|
||||
|
||||
return {
|
||||
root: allParts[0],
|
||||
dir: allParts[0] + allParts[1].slice(0, -1),
|
||||
base: allParts[2],
|
||||
ext: allParts[3],
|
||||
name: allParts[2].slice(0, allParts[2].length - allParts[3].length)
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
posix.sep = '/';
|
||||
posix.delimiter = ':';
|
||||
|
||||
|
||||
if (isWindows)
|
||||
module.exports = win32;
|
||||
else /* posix */
|
||||
module.exports = posix;
|
||||
|
||||
module.exports.posix = posix;
|
||||
module.exports.win32 = win32;
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
{
|
||||
extends: "eslint:recommended",
|
||||
"env": {
|
||||
"node": true,
|
||||
"browser": true,
|
||||
"es6" : true,
|
||||
"mocha": true
|
||||
},
|
||||
"rules": {
|
||||
"indent": [2, 4],
|
||||
"brace-style": [2, "1tbs"],
|
||||
"quotes": [2, "single"],
|
||||
"no-console": 0,
|
||||
"no-shadow": 0,
|
||||
"no-use-before-define": [2, "nofunc"],
|
||||
"no-underscore-dangle": 0,
|
||||
"no-constant-condition": 0,
|
||||
"space-after-function-name": 0,
|
||||
"consistent-return": 0
|
||||
}
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
(The MIT License)
|
||||
|
||||
Copyright (c) 2013 Roman Shtylman <shtylman@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
'Software'), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
# process
|
||||
|
||||
```require('process');``` just like any other module.
|
||||
|
||||
Works in node.js and browsers via the browser.js shim provided with the module.
|
||||
|
||||
## browser implementation
|
||||
|
||||
The goal of this module is not to be a full-fledged alternative to the builtin process module. This module mostly exists to provide the nextTick functionality and little more. We keep this module lean because it will often be included by default by tools like browserify when it detects a module has used the `process` global.
|
||||
|
||||
It also exposes a "browser" member (i.e. `process.browser`) which is `true` in this implementation but `undefined` in node. This can be used in isomorphic code that adjusts it's behavior depending on which environment it's running in.
|
||||
|
||||
If you are looking to provide other process methods, I suggest you monkey patch them onto the process global in your app. A list of user created patches is below.
|
||||
|
||||
* [hrtime](https://github.com/kumavis/browser-process-hrtime)
|
||||
* [stdout](https://github.com/kumavis/browser-stdout)
|
||||
|
||||
## package manager notes
|
||||
|
||||
If you are writing a bundler to package modules for client side use, make sure you use the ```browser``` field hint in package.json.
|
||||
|
||||
See https://gist.github.com/4339901 for details.
|
||||
|
||||
The [browserify](https://github.com/substack/node-browserify) module will properly handle this field when bundling your files.
|
||||
|
||||
|
||||
-184
@@ -1,184 +0,0 @@
|
||||
// shim for using process in browser
|
||||
var process = module.exports = {};
|
||||
|
||||
// cached from whatever global is present so that test runners that stub it
|
||||
// don't break things. But we need to wrap it in a try catch in case it is
|
||||
// wrapped in strict mode code which doesn't define any globals. It's inside a
|
||||
// function because try/catches deoptimize in certain engines.
|
||||
|
||||
var cachedSetTimeout;
|
||||
var cachedClearTimeout;
|
||||
|
||||
function defaultSetTimout() {
|
||||
throw new Error('setTimeout has not been defined');
|
||||
}
|
||||
function defaultClearTimeout () {
|
||||
throw new Error('clearTimeout has not been defined');
|
||||
}
|
||||
(function () {
|
||||
try {
|
||||
if (typeof setTimeout === 'function') {
|
||||
cachedSetTimeout = setTimeout;
|
||||
} else {
|
||||
cachedSetTimeout = defaultSetTimout;
|
||||
}
|
||||
} catch (e) {
|
||||
cachedSetTimeout = defaultSetTimout;
|
||||
}
|
||||
try {
|
||||
if (typeof clearTimeout === 'function') {
|
||||
cachedClearTimeout = clearTimeout;
|
||||
} else {
|
||||
cachedClearTimeout = defaultClearTimeout;
|
||||
}
|
||||
} catch (e) {
|
||||
cachedClearTimeout = defaultClearTimeout;
|
||||
}
|
||||
} ())
|
||||
function runTimeout(fun) {
|
||||
if (cachedSetTimeout === setTimeout) {
|
||||
//normal enviroments in sane situations
|
||||
return setTimeout(fun, 0);
|
||||
}
|
||||
// if setTimeout wasn't available but was latter defined
|
||||
if ((cachedSetTimeout === defaultSetTimout || !cachedSetTimeout) && setTimeout) {
|
||||
cachedSetTimeout = setTimeout;
|
||||
return setTimeout(fun, 0);
|
||||
}
|
||||
try {
|
||||
// when when somebody has screwed with setTimeout but no I.E. maddness
|
||||
return cachedSetTimeout(fun, 0);
|
||||
} catch(e){
|
||||
try {
|
||||
// When we are in I.E. but the script has been evaled so I.E. doesn't trust the global object when called normally
|
||||
return cachedSetTimeout.call(null, fun, 0);
|
||||
} catch(e){
|
||||
// same as above but when it's a version of I.E. that must have the global object for 'this', hopfully our context correct otherwise it will throw a global error
|
||||
return cachedSetTimeout.call(this, fun, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
function runClearTimeout(marker) {
|
||||
if (cachedClearTimeout === clearTimeout) {
|
||||
//normal enviroments in sane situations
|
||||
return clearTimeout(marker);
|
||||
}
|
||||
// if clearTimeout wasn't available but was latter defined
|
||||
if ((cachedClearTimeout === defaultClearTimeout || !cachedClearTimeout) && clearTimeout) {
|
||||
cachedClearTimeout = clearTimeout;
|
||||
return clearTimeout(marker);
|
||||
}
|
||||
try {
|
||||
// when when somebody has screwed with setTimeout but no I.E. maddness
|
||||
return cachedClearTimeout(marker);
|
||||
} catch (e){
|
||||
try {
|
||||
// When we are in I.E. but the script has been evaled so I.E. doesn't trust the global object when called normally
|
||||
return cachedClearTimeout.call(null, marker);
|
||||
} catch (e){
|
||||
// same as above but when it's a version of I.E. that must have the global object for 'this', hopfully our context correct otherwise it will throw a global error.
|
||||
// Some versions of I.E. have different rules for clearTimeout vs setTimeout
|
||||
return cachedClearTimeout.call(this, marker);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
var queue = [];
|
||||
var draining = false;
|
||||
var currentQueue;
|
||||
var queueIndex = -1;
|
||||
|
||||
function cleanUpNextTick() {
|
||||
if (!draining || !currentQueue) {
|
||||
return;
|
||||
}
|
||||
draining = false;
|
||||
if (currentQueue.length) {
|
||||
queue = currentQueue.concat(queue);
|
||||
} else {
|
||||
queueIndex = -1;
|
||||
}
|
||||
if (queue.length) {
|
||||
drainQueue();
|
||||
}
|
||||
}
|
||||
|
||||
function drainQueue() {
|
||||
if (draining) {
|
||||
return;
|
||||
}
|
||||
var timeout = runTimeout(cleanUpNextTick);
|
||||
draining = true;
|
||||
|
||||
var len = queue.length;
|
||||
while(len) {
|
||||
currentQueue = queue;
|
||||
queue = [];
|
||||
while (++queueIndex < len) {
|
||||
if (currentQueue) {
|
||||
currentQueue[queueIndex].run();
|
||||
}
|
||||
}
|
||||
queueIndex = -1;
|
||||
len = queue.length;
|
||||
}
|
||||
currentQueue = null;
|
||||
draining = false;
|
||||
runClearTimeout(timeout);
|
||||
}
|
||||
|
||||
process.nextTick = function (fun) {
|
||||
var args = new Array(arguments.length - 1);
|
||||
if (arguments.length > 1) {
|
||||
for (var i = 1; i < arguments.length; i++) {
|
||||
args[i - 1] = arguments[i];
|
||||
}
|
||||
}
|
||||
queue.push(new Item(fun, args));
|
||||
if (queue.length === 1 && !draining) {
|
||||
runTimeout(drainQueue);
|
||||
}
|
||||
};
|
||||
|
||||
// v8 likes predictible objects
|
||||
function Item(fun, array) {
|
||||
this.fun = fun;
|
||||
this.array = array;
|
||||
}
|
||||
Item.prototype.run = function () {
|
||||
this.fun.apply(null, this.array);
|
||||
};
|
||||
process.title = 'browser';
|
||||
process.browser = true;
|
||||
process.env = {};
|
||||
process.argv = [];
|
||||
process.version = ''; // empty string to avoid regexp issues
|
||||
process.versions = {};
|
||||
|
||||
function noop() {}
|
||||
|
||||
process.on = noop;
|
||||
process.addListener = noop;
|
||||
process.once = noop;
|
||||
process.off = noop;
|
||||
process.removeListener = noop;
|
||||
process.removeAllListeners = noop;
|
||||
process.emit = noop;
|
||||
process.prependListener = noop;
|
||||
process.prependOnceListener = noop;
|
||||
|
||||
process.listeners = function (name) { return [] }
|
||||
|
||||
process.binding = function (name) {
|
||||
throw new Error('process.binding is not supported');
|
||||
};
|
||||
|
||||
process.cwd = function () { return '/' };
|
||||
process.chdir = function (dir) {
|
||||
throw new Error('process.chdir is not supported');
|
||||
};
|
||||
process.umask = function() { return 0; };
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
// for now just expose the builtin process global from node.js
|
||||
module.exports = global.process;
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"author": "Roman Shtylman <shtylman@gmail.com>",
|
||||
"name": "process",
|
||||
"description": "process information for node.js and browsers",
|
||||
"keywords": [
|
||||
"process"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "mocha test.js",
|
||||
"browser": "zuul --no-coverage --ui mocha-bdd --local 8080 -- test.js"
|
||||
},
|
||||
"version": "0.11.10",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/shtylman/node-process.git"
|
||||
},
|
||||
"license": "MIT",
|
||||
"browser": "./browser.js",
|
||||
"main": "./index.js",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mocha": "2.2.1",
|
||||
"zuul": "^3.10.3"
|
||||
}
|
||||
}
|
||||
-199
@@ -1,199 +0,0 @@
|
||||
var assert = require('assert');
|
||||
var ourProcess = require('./browser');
|
||||
describe('test against our process', function () {
|
||||
test(ourProcess);
|
||||
});
|
||||
if (!process.browser) {
|
||||
describe('test against node', function () {
|
||||
test(process);
|
||||
});
|
||||
vmtest();
|
||||
}
|
||||
function test (ourProcess) {
|
||||
describe('test arguments', function () {
|
||||
it ('works', function (done) {
|
||||
var order = 0;
|
||||
|
||||
|
||||
ourProcess.nextTick(function (num) {
|
||||
assert.equal(num, order++, 'first one works');
|
||||
ourProcess.nextTick(function (num) {
|
||||
assert.equal(num, order++, 'recursive one is 4th');
|
||||
}, 3);
|
||||
}, 0);
|
||||
ourProcess.nextTick(function (num) {
|
||||
assert.equal(num, order++, 'second one starts');
|
||||
ourProcess.nextTick(function (num) {
|
||||
assert.equal(num, order++, 'this is third');
|
||||
ourProcess.nextTick(function (num) {
|
||||
assert.equal(num, order++, 'this is last');
|
||||
done();
|
||||
}, 5);
|
||||
}, 4);
|
||||
}, 1);
|
||||
ourProcess.nextTick(function (num) {
|
||||
|
||||
assert.equal(num, order++, '3rd schedualed happens after the error');
|
||||
}, 2);
|
||||
});
|
||||
});
|
||||
if (!process.browser) {
|
||||
describe('test errors', function (t) {
|
||||
it ('works', function (done) {
|
||||
var order = 0;
|
||||
process.removeAllListeners('uncaughtException');
|
||||
process.once('uncaughtException', function(err) {
|
||||
assert.equal(2, order++, 'error is third');
|
||||
ourProcess.nextTick(function () {
|
||||
assert.equal(5, order++, 'schedualed in error is last');
|
||||
done();
|
||||
});
|
||||
});
|
||||
ourProcess.nextTick(function () {
|
||||
assert.equal(0, order++, 'first one works');
|
||||
ourProcess.nextTick(function () {
|
||||
assert.equal(4, order++, 'recursive one is 4th');
|
||||
});
|
||||
});
|
||||
ourProcess.nextTick(function () {
|
||||
assert.equal(1, order++, 'second one starts');
|
||||
throw(new Error('an error is thrown'));
|
||||
});
|
||||
ourProcess.nextTick(function () {
|
||||
assert.equal(3, order++, '3rd schedualed happens after the error');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
describe('rename globals', function (t) {
|
||||
var oldTimeout = setTimeout;
|
||||
var oldClear = clearTimeout;
|
||||
|
||||
it('clearTimeout', function (done){
|
||||
|
||||
var ok = true;
|
||||
clearTimeout = function () {
|
||||
ok = false;
|
||||
}
|
||||
var ran = false;
|
||||
function cleanup() {
|
||||
clearTimeout = oldClear;
|
||||
var err;
|
||||
try {
|
||||
assert.ok(ok, 'fake clearTimeout ran');
|
||||
assert.ok(ran, 'should have run');
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
done(err);
|
||||
}
|
||||
setTimeout(cleanup, 1000);
|
||||
ourProcess.nextTick(function () {
|
||||
ran = true;
|
||||
});
|
||||
});
|
||||
it('just setTimeout', function (done){
|
||||
|
||||
|
||||
setTimeout = function () {
|
||||
setTimeout = oldTimeout;
|
||||
try {
|
||||
assert.ok(false, 'fake setTimeout called')
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ourProcess.nextTick(function () {
|
||||
setTimeout = oldTimeout;
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
function vmtest() {
|
||||
var vm = require('vm');
|
||||
var fs = require('fs');
|
||||
var process = fs.readFileSync('./browser.js', {encoding: 'utf8'});
|
||||
|
||||
|
||||
describe('should work in vm in strict mode with no globals', function () {
|
||||
it('should parse', function (done) {
|
||||
var str = '"use strict";var module = {exports:{}};';
|
||||
str += process;
|
||||
str += 'this.works = process.browser;';
|
||||
var script = new vm.Script(str);
|
||||
var context = {
|
||||
works: false
|
||||
};
|
||||
script.runInNewContext(context);
|
||||
assert.ok(context.works);
|
||||
done();
|
||||
});
|
||||
it('setTimeout throws error', function (done) {
|
||||
var str = '"use strict";var module = {exports:{}};';
|
||||
str += process;
|
||||
str += 'try {process.nextTick(function () {})} catch (e){this.works = e;}';
|
||||
var script = new vm.Script(str);
|
||||
var context = {
|
||||
works: false
|
||||
};
|
||||
script.runInNewContext(context);
|
||||
assert.ok(context.works);
|
||||
done();
|
||||
});
|
||||
it('should generally work', function (done) {
|
||||
var str = '"use strict";var module = {exports:{}};';
|
||||
str += process;
|
||||
str += 'process.nextTick(function () {assert.ok(true);done();})';
|
||||
var script = new vm.Script(str);
|
||||
var context = {
|
||||
clearTimeout: clearTimeout,
|
||||
setTimeout: setTimeout,
|
||||
done: done,
|
||||
assert: assert
|
||||
};
|
||||
script.runInNewContext(context);
|
||||
});
|
||||
it('late defs setTimeout', function (done) {
|
||||
var str = '"use strict";var module = {exports:{}};';
|
||||
str += process;
|
||||
str += 'var setTimeout = hiddenSetTimeout;process.nextTick(function () {assert.ok(true);done();})';
|
||||
var script = new vm.Script(str);
|
||||
var context = {
|
||||
clearTimeout: clearTimeout,
|
||||
hiddenSetTimeout: setTimeout,
|
||||
done: done,
|
||||
assert: assert
|
||||
};
|
||||
script.runInNewContext(context);
|
||||
});
|
||||
it('late defs clearTimeout', function (done) {
|
||||
var str = '"use strict";var module = {exports:{}};';
|
||||
str += process;
|
||||
str += 'var clearTimeout = hiddenClearTimeout;process.nextTick(function () {assert.ok(true);done();})';
|
||||
var script = new vm.Script(str);
|
||||
var context = {
|
||||
hiddenClearTimeout: clearTimeout,
|
||||
setTimeout: setTimeout,
|
||||
done: done,
|
||||
assert: assert
|
||||
};
|
||||
script.runInNewContext(context);
|
||||
});
|
||||
it('late defs setTimeout and then redefine', function (done) {
|
||||
var str = '"use strict";var module = {exports:{}};';
|
||||
str += process;
|
||||
str += 'var setTimeout = hiddenSetTimeout;process.nextTick(function () {setTimeout = function (){throw new Error("foo")};hiddenSetTimeout(function(){process.nextTick(function (){assert.ok(true);done();});});});';
|
||||
var script = new vm.Script(str);
|
||||
var context = {
|
||||
clearTimeout: clearTimeout,
|
||||
hiddenSetTimeout: setTimeout,
|
||||
done: done,
|
||||
assert: assert
|
||||
};
|
||||
script.runInNewContext(context);
|
||||
});
|
||||
});
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to
|
||||
deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
# util
|
||||
|
||||
[](https://travis-ci.org/defunctzombie/node-util)
|
||||
|
||||
node.js [util](http://nodejs.org/api/util.html) module as a module
|
||||
|
||||
## install via [npm](npmjs.org)
|
||||
|
||||
```shell
|
||||
npm install util
|
||||
```
|
||||
|
||||
## browser support
|
||||
|
||||
This module also works in modern browsers. If you need legacy browser support you will need to polyfill ES5 features.
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
The ISC License
|
||||
|
||||
Copyright (c) Isaac Z. Schlueter
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
Browser-friendly inheritance fully compatible with standard node.js
|
||||
[inherits](http://nodejs.org/api/util.html#util_util_inherits_constructor_superconstructor).
|
||||
|
||||
This package exports standard `inherits` from node.js `util` module in
|
||||
node environment, but also provides alternative browser-friendly
|
||||
implementation through [browser
|
||||
field](https://gist.github.com/shtylman/4339901). Alternative
|
||||
implementation is a literal copy of standard one located in standalone
|
||||
module to avoid requiring of `util`. It also has a shim for old
|
||||
browsers with no `Object.create` support.
|
||||
|
||||
While keeping you sure you are using standard `inherits`
|
||||
implementation in node.js environment, it allows bundlers such as
|
||||
[browserify](https://github.com/substack/node-browserify) to not
|
||||
include full `util` package to your client code if all you need is
|
||||
just `inherits` function. It worth, because browser shim for `util`
|
||||
package is large and `inherits` is often the single function you need
|
||||
from it.
|
||||
|
||||
It's recommended to use this package instead of
|
||||
`require('util').inherits` for any code that has chances to be used
|
||||
not only in node.js but in browser too.
|
||||
|
||||
## usage
|
||||
|
||||
```js
|
||||
var inherits = require('inherits');
|
||||
// then use exactly as the standard one
|
||||
```
|
||||
|
||||
## note on version ~1.0
|
||||
|
||||
Version ~1.0 had completely different motivation and is not compatible
|
||||
neither with 2.0 nor with standard node.js `inherits`.
|
||||
|
||||
If you are using version ~1.0 and planning to switch to ~2.0, be
|
||||
careful:
|
||||
|
||||
* new version uses `super_` instead of `super` for referencing
|
||||
superclass
|
||||
* new version overwrites current prototype while old one preserves any
|
||||
existing fields on it
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
try {
|
||||
var util = require('util');
|
||||
if (typeof util.inherits !== 'function') throw '';
|
||||
module.exports = util.inherits;
|
||||
} catch (e) {
|
||||
module.exports = require('./inherits_browser.js');
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
if (typeof Object.create === 'function') {
|
||||
// implementation from standard node.js 'util' module
|
||||
module.exports = function inherits(ctor, superCtor) {
|
||||
ctor.super_ = superCtor
|
||||
ctor.prototype = Object.create(superCtor.prototype, {
|
||||
constructor: {
|
||||
value: ctor,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
configurable: true
|
||||
}
|
||||
});
|
||||
};
|
||||
} else {
|
||||
// old school shim for old browsers
|
||||
module.exports = function inherits(ctor, superCtor) {
|
||||
ctor.super_ = superCtor
|
||||
var TempCtor = function () {}
|
||||
TempCtor.prototype = superCtor.prototype
|
||||
ctor.prototype = new TempCtor()
|
||||
ctor.prototype.constructor = ctor
|
||||
}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "inherits",
|
||||
"description": "Browser-friendly inheritance fully compatible with standard node.js inherits()",
|
||||
"version": "2.0.3",
|
||||
"keywords": [
|
||||
"inheritance",
|
||||
"class",
|
||||
"klass",
|
||||
"oop",
|
||||
"object-oriented",
|
||||
"inherits",
|
||||
"browser",
|
||||
"browserify"
|
||||
],
|
||||
"main": "./inherits.js",
|
||||
"browser": "./inherits_browser.js",
|
||||
"repository": "git://github.com/isaacs/inherits",
|
||||
"license": "ISC",
|
||||
"scripts": {
|
||||
"test": "node test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tap": "^7.1.0"
|
||||
},
|
||||
"files": [
|
||||
"inherits.js",
|
||||
"inherits_browser.js"
|
||||
]
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"author": {
|
||||
"name": "Joyent",
|
||||
"url": "http://www.joyent.com"
|
||||
},
|
||||
"name": "util",
|
||||
"description": "Node.JS util module",
|
||||
"keywords": [
|
||||
"util"
|
||||
],
|
||||
"version": "0.10.4",
|
||||
"homepage": "https://github.com/defunctzombie/node-util",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git://github.com/defunctzombie/node-util"
|
||||
},
|
||||
"main": "./util.js",
|
||||
"files": [
|
||||
"util.js",
|
||||
"support"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node test/node/*.js && zuul test/browser/*.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"inherits": "2.0.3"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"zuul": "~1.0.9"
|
||||
},
|
||||
"browser": {
|
||||
"./support/isBuffer.js": "./support/isBufferBrowser.js"
|
||||
}
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
module.exports = function isBuffer(arg) {
|
||||
return arg instanceof Buffer;
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
module.exports = function isBuffer(arg) {
|
||||
return arg && typeof arg === 'object'
|
||||
&& typeof arg.copy === 'function'
|
||||
&& typeof arg.fill === 'function'
|
||||
&& typeof arg.readUInt8 === 'function';
|
||||
}
|
||||
-586
@@ -1,586 +0,0 @@
|
||||
// Copyright Joyent, Inc. and other Node contributors.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
// copy of this software and associated documentation files (the
|
||||
// "Software"), to deal in the Software without restriction, including
|
||||
// without limitation the rights to use, copy, modify, merge, publish,
|
||||
// distribute, sublicense, and/or sell copies of the Software, and to permit
|
||||
// persons to whom the Software is furnished to do so, subject to the
|
||||
// following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included
|
||||
// in all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
||||
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
// USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
var formatRegExp = /%[sdj%]/g;
|
||||
exports.format = function(f) {
|
||||
if (!isString(f)) {
|
||||
var objects = [];
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
objects.push(inspect(arguments[i]));
|
||||
}
|
||||
return objects.join(' ');
|
||||
}
|
||||
|
||||
var i = 1;
|
||||
var args = arguments;
|
||||
var len = args.length;
|
||||
var str = String(f).replace(formatRegExp, function(x) {
|
||||
if (x === '%%') return '%';
|
||||
if (i >= len) return x;
|
||||
switch (x) {
|
||||
case '%s': return String(args[i++]);
|
||||
case '%d': return Number(args[i++]);
|
||||
case '%j':
|
||||
try {
|
||||
return JSON.stringify(args[i++]);
|
||||
} catch (_) {
|
||||
return '[Circular]';
|
||||
}
|
||||
default:
|
||||
return x;
|
||||
}
|
||||
});
|
||||
for (var x = args[i]; i < len; x = args[++i]) {
|
||||
if (isNull(x) || !isObject(x)) {
|
||||
str += ' ' + x;
|
||||
} else {
|
||||
str += ' ' + inspect(x);
|
||||
}
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
|
||||
// Mark that a method should not be used.
|
||||
// Returns a modified function which warns once by default.
|
||||
// If --no-deprecation is set, then it is a no-op.
|
||||
exports.deprecate = function(fn, msg) {
|
||||
// Allow for deprecating things in the process of starting up.
|
||||
if (isUndefined(global.process)) {
|
||||
return function() {
|
||||
return exports.deprecate(fn, msg).apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
if (process.noDeprecation === true) {
|
||||
return fn;
|
||||
}
|
||||
|
||||
var warned = false;
|
||||
function deprecated() {
|
||||
if (!warned) {
|
||||
if (process.throwDeprecation) {
|
||||
throw new Error(msg);
|
||||
} else if (process.traceDeprecation) {
|
||||
console.trace(msg);
|
||||
} else {
|
||||
console.error(msg);
|
||||
}
|
||||
warned = true;
|
||||
}
|
||||
return fn.apply(this, arguments);
|
||||
}
|
||||
|
||||
return deprecated;
|
||||
};
|
||||
|
||||
|
||||
var debugs = {};
|
||||
var debugEnviron;
|
||||
exports.debuglog = function(set) {
|
||||
if (isUndefined(debugEnviron))
|
||||
debugEnviron = process.env.NODE_DEBUG || '';
|
||||
set = set.toUpperCase();
|
||||
if (!debugs[set]) {
|
||||
if (new RegExp('\\b' + set + '\\b', 'i').test(debugEnviron)) {
|
||||
var pid = process.pid;
|
||||
debugs[set] = function() {
|
||||
var msg = exports.format.apply(exports, arguments);
|
||||
console.error('%s %d: %s', set, pid, msg);
|
||||
};
|
||||
} else {
|
||||
debugs[set] = function() {};
|
||||
}
|
||||
}
|
||||
return debugs[set];
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Echos the value of a value. Trys to print the value out
|
||||
* in the best way possible given the different types.
|
||||
*
|
||||
* @param {Object} obj The object to print out.
|
||||
* @param {Object} opts Optional options object that alters the output.
|
||||
*/
|
||||
/* legacy: obj, showHidden, depth, colors*/
|
||||
function inspect(obj, opts) {
|
||||
// default options
|
||||
var ctx = {
|
||||
seen: [],
|
||||
stylize: stylizeNoColor
|
||||
};
|
||||
// legacy...
|
||||
if (arguments.length >= 3) ctx.depth = arguments[2];
|
||||
if (arguments.length >= 4) ctx.colors = arguments[3];
|
||||
if (isBoolean(opts)) {
|
||||
// legacy...
|
||||
ctx.showHidden = opts;
|
||||
} else if (opts) {
|
||||
// got an "options" object
|
||||
exports._extend(ctx, opts);
|
||||
}
|
||||
// set default options
|
||||
if (isUndefined(ctx.showHidden)) ctx.showHidden = false;
|
||||
if (isUndefined(ctx.depth)) ctx.depth = 2;
|
||||
if (isUndefined(ctx.colors)) ctx.colors = false;
|
||||
if (isUndefined(ctx.customInspect)) ctx.customInspect = true;
|
||||
if (ctx.colors) ctx.stylize = stylizeWithColor;
|
||||
return formatValue(ctx, obj, ctx.depth);
|
||||
}
|
||||
exports.inspect = inspect;
|
||||
|
||||
|
||||
// http://en.wikipedia.org/wiki/ANSI_escape_code#graphics
|
||||
inspect.colors = {
|
||||
'bold' : [1, 22],
|
||||
'italic' : [3, 23],
|
||||
'underline' : [4, 24],
|
||||
'inverse' : [7, 27],
|
||||
'white' : [37, 39],
|
||||
'grey' : [90, 39],
|
||||
'black' : [30, 39],
|
||||
'blue' : [34, 39],
|
||||
'cyan' : [36, 39],
|
||||
'green' : [32, 39],
|
||||
'magenta' : [35, 39],
|
||||
'red' : [31, 39],
|
||||
'yellow' : [33, 39]
|
||||
};
|
||||
|
||||
// Don't use 'blue' not visible on cmd.exe
|
||||
inspect.styles = {
|
||||
'special': 'cyan',
|
||||
'number': 'yellow',
|
||||
'boolean': 'yellow',
|
||||
'undefined': 'grey',
|
||||
'null': 'bold',
|
||||
'string': 'green',
|
||||
'date': 'magenta',
|
||||
// "name": intentionally not styling
|
||||
'regexp': 'red'
|
||||
};
|
||||
|
||||
|
||||
function stylizeWithColor(str, styleType) {
|
||||
var style = inspect.styles[styleType];
|
||||
|
||||
if (style) {
|
||||
return '\u001b[' + inspect.colors[style][0] + 'm' + str +
|
||||
'\u001b[' + inspect.colors[style][1] + 'm';
|
||||
} else {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function stylizeNoColor(str, styleType) {
|
||||
return str;
|
||||
}
|
||||
|
||||
|
||||
function arrayToHash(array) {
|
||||
var hash = {};
|
||||
|
||||
array.forEach(function(val, idx) {
|
||||
hash[val] = true;
|
||||
});
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
|
||||
function formatValue(ctx, value, recurseTimes) {
|
||||
// Provide a hook for user-specified inspect functions.
|
||||
// Check that value is an object with an inspect function on it
|
||||
if (ctx.customInspect &&
|
||||
value &&
|
||||
isFunction(value.inspect) &&
|
||||
// Filter out the util module, it's inspect function is special
|
||||
value.inspect !== exports.inspect &&
|
||||
// Also filter out any prototype objects using the circular check.
|
||||
!(value.constructor && value.constructor.prototype === value)) {
|
||||
var ret = value.inspect(recurseTimes, ctx);
|
||||
if (!isString(ret)) {
|
||||
ret = formatValue(ctx, ret, recurseTimes);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
// Primitive types cannot have properties
|
||||
var primitive = formatPrimitive(ctx, value);
|
||||
if (primitive) {
|
||||
return primitive;
|
||||
}
|
||||
|
||||
// Look up the keys of the object.
|
||||
var keys = Object.keys(value);
|
||||
var visibleKeys = arrayToHash(keys);
|
||||
|
||||
if (ctx.showHidden) {
|
||||
keys = Object.getOwnPropertyNames(value);
|
||||
}
|
||||
|
||||
// IE doesn't make error fields non-enumerable
|
||||
// http://msdn.microsoft.com/en-us/library/ie/dww52sbt(v=vs.94).aspx
|
||||
if (isError(value)
|
||||
&& (keys.indexOf('message') >= 0 || keys.indexOf('description') >= 0)) {
|
||||
return formatError(value);
|
||||
}
|
||||
|
||||
// Some type of object without properties can be shortcutted.
|
||||
if (keys.length === 0) {
|
||||
if (isFunction(value)) {
|
||||
var name = value.name ? ': ' + value.name : '';
|
||||
return ctx.stylize('[Function' + name + ']', 'special');
|
||||
}
|
||||
if (isRegExp(value)) {
|
||||
return ctx.stylize(RegExp.prototype.toString.call(value), 'regexp');
|
||||
}
|
||||
if (isDate(value)) {
|
||||
return ctx.stylize(Date.prototype.toString.call(value), 'date');
|
||||
}
|
||||
if (isError(value)) {
|
||||
return formatError(value);
|
||||
}
|
||||
}
|
||||
|
||||
var base = '', array = false, braces = ['{', '}'];
|
||||
|
||||
// Make Array say that they are Array
|
||||
if (isArray(value)) {
|
||||
array = true;
|
||||
braces = ['[', ']'];
|
||||
}
|
||||
|
||||
// Make functions say that they are functions
|
||||
if (isFunction(value)) {
|
||||
var n = value.name ? ': ' + value.name : '';
|
||||
base = ' [Function' + n + ']';
|
||||
}
|
||||
|
||||
// Make RegExps say that they are RegExps
|
||||
if (isRegExp(value)) {
|
||||
base = ' ' + RegExp.prototype.toString.call(value);
|
||||
}
|
||||
|
||||
// Make dates with properties first say the date
|
||||
if (isDate(value)) {
|
||||
base = ' ' + Date.prototype.toUTCString.call(value);
|
||||
}
|
||||
|
||||
// Make error with message first say the error
|
||||
if (isError(value)) {
|
||||
base = ' ' + formatError(value);
|
||||
}
|
||||
|
||||
if (keys.length === 0 && (!array || value.length == 0)) {
|
||||
return braces[0] + base + braces[1];
|
||||
}
|
||||
|
||||
if (recurseTimes < 0) {
|
||||
if (isRegExp(value)) {
|
||||
return ctx.stylize(RegExp.prototype.toString.call(value), 'regexp');
|
||||
} else {
|
||||
return ctx.stylize('[Object]', 'special');
|
||||
}
|
||||
}
|
||||
|
||||
ctx.seen.push(value);
|
||||
|
||||
var output;
|
||||
if (array) {
|
||||
output = formatArray(ctx, value, recurseTimes, visibleKeys, keys);
|
||||
} else {
|
||||
output = keys.map(function(key) {
|
||||
return formatProperty(ctx, value, recurseTimes, visibleKeys, key, array);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.seen.pop();
|
||||
|
||||
return reduceToSingleString(output, base, braces);
|
||||
}
|
||||
|
||||
|
||||
function formatPrimitive(ctx, value) {
|
||||
if (isUndefined(value))
|
||||
return ctx.stylize('undefined', 'undefined');
|
||||
if (isString(value)) {
|
||||
var simple = '\'' + JSON.stringify(value).replace(/^"|"$/g, '')
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/\\"/g, '"') + '\'';
|
||||
return ctx.stylize(simple, 'string');
|
||||
}
|
||||
if (isNumber(value))
|
||||
return ctx.stylize('' + value, 'number');
|
||||
if (isBoolean(value))
|
||||
return ctx.stylize('' + value, 'boolean');
|
||||
// For some reason typeof null is "object", so special case here.
|
||||
if (isNull(value))
|
||||
return ctx.stylize('null', 'null');
|
||||
}
|
||||
|
||||
|
||||
function formatError(value) {
|
||||
return '[' + Error.prototype.toString.call(value) + ']';
|
||||
}
|
||||
|
||||
|
||||
function formatArray(ctx, value, recurseTimes, visibleKeys, keys) {
|
||||
var output = [];
|
||||
for (var i = 0, l = value.length; i < l; ++i) {
|
||||
if (hasOwnProperty(value, String(i))) {
|
||||
output.push(formatProperty(ctx, value, recurseTimes, visibleKeys,
|
||||
String(i), true));
|
||||
} else {
|
||||
output.push('');
|
||||
}
|
||||
}
|
||||
keys.forEach(function(key) {
|
||||
if (!key.match(/^\d+$/)) {
|
||||
output.push(formatProperty(ctx, value, recurseTimes, visibleKeys,
|
||||
key, true));
|
||||
}
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
function formatProperty(ctx, value, recurseTimes, visibleKeys, key, array) {
|
||||
var name, str, desc;
|
||||
desc = Object.getOwnPropertyDescriptor(value, key) || { value: value[key] };
|
||||
if (desc.get) {
|
||||
if (desc.set) {
|
||||
str = ctx.stylize('[Getter/Setter]', 'special');
|
||||
} else {
|
||||
str = ctx.stylize('[Getter]', 'special');
|
||||
}
|
||||
} else {
|
||||
if (desc.set) {
|
||||
str = ctx.stylize('[Setter]', 'special');
|
||||
}
|
||||
}
|
||||
if (!hasOwnProperty(visibleKeys, key)) {
|
||||
name = '[' + key + ']';
|
||||
}
|
||||
if (!str) {
|
||||
if (ctx.seen.indexOf(desc.value) < 0) {
|
||||
if (isNull(recurseTimes)) {
|
||||
str = formatValue(ctx, desc.value, null);
|
||||
} else {
|
||||
str = formatValue(ctx, desc.value, recurseTimes - 1);
|
||||
}
|
||||
if (str.indexOf('\n') > -1) {
|
||||
if (array) {
|
||||
str = str.split('\n').map(function(line) {
|
||||
return ' ' + line;
|
||||
}).join('\n').substr(2);
|
||||
} else {
|
||||
str = '\n' + str.split('\n').map(function(line) {
|
||||
return ' ' + line;
|
||||
}).join('\n');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
str = ctx.stylize('[Circular]', 'special');
|
||||
}
|
||||
}
|
||||
if (isUndefined(name)) {
|
||||
if (array && key.match(/^\d+$/)) {
|
||||
return str;
|
||||
}
|
||||
name = JSON.stringify('' + key);
|
||||
if (name.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)) {
|
||||
name = name.substr(1, name.length - 2);
|
||||
name = ctx.stylize(name, 'name');
|
||||
} else {
|
||||
name = name.replace(/'/g, "\\'")
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/(^"|"$)/g, "'");
|
||||
name = ctx.stylize(name, 'string');
|
||||
}
|
||||
}
|
||||
|
||||
return name + ': ' + str;
|
||||
}
|
||||
|
||||
|
||||
function reduceToSingleString(output, base, braces) {
|
||||
var numLinesEst = 0;
|
||||
var length = output.reduce(function(prev, cur) {
|
||||
numLinesEst++;
|
||||
if (cur.indexOf('\n') >= 0) numLinesEst++;
|
||||
return prev + cur.replace(/\u001b\[\d\d?m/g, '').length + 1;
|
||||
}, 0);
|
||||
|
||||
if (length > 60) {
|
||||
return braces[0] +
|
||||
(base === '' ? '' : base + '\n ') +
|
||||
' ' +
|
||||
output.join(',\n ') +
|
||||
' ' +
|
||||
braces[1];
|
||||
}
|
||||
|
||||
return braces[0] + base + ' ' + output.join(', ') + ' ' + braces[1];
|
||||
}
|
||||
|
||||
|
||||
// NOTE: These type checking functions intentionally don't use `instanceof`
|
||||
// because it is fragile and can be easily faked with `Object.create()`.
|
||||
function isArray(ar) {
|
||||
return Array.isArray(ar);
|
||||
}
|
||||
exports.isArray = isArray;
|
||||
|
||||
function isBoolean(arg) {
|
||||
return typeof arg === 'boolean';
|
||||
}
|
||||
exports.isBoolean = isBoolean;
|
||||
|
||||
function isNull(arg) {
|
||||
return arg === null;
|
||||
}
|
||||
exports.isNull = isNull;
|
||||
|
||||
function isNullOrUndefined(arg) {
|
||||
return arg == null;
|
||||
}
|
||||
exports.isNullOrUndefined = isNullOrUndefined;
|
||||
|
||||
function isNumber(arg) {
|
||||
return typeof arg === 'number';
|
||||
}
|
||||
exports.isNumber = isNumber;
|
||||
|
||||
function isString(arg) {
|
||||
return typeof arg === 'string';
|
||||
}
|
||||
exports.isString = isString;
|
||||
|
||||
function isSymbol(arg) {
|
||||
return typeof arg === 'symbol';
|
||||
}
|
||||
exports.isSymbol = isSymbol;
|
||||
|
||||
function isUndefined(arg) {
|
||||
return arg === void 0;
|
||||
}
|
||||
exports.isUndefined = isUndefined;
|
||||
|
||||
function isRegExp(re) {
|
||||
return isObject(re) && objectToString(re) === '[object RegExp]';
|
||||
}
|
||||
exports.isRegExp = isRegExp;
|
||||
|
||||
function isObject(arg) {
|
||||
return typeof arg === 'object' && arg !== null;
|
||||
}
|
||||
exports.isObject = isObject;
|
||||
|
||||
function isDate(d) {
|
||||
return isObject(d) && objectToString(d) === '[object Date]';
|
||||
}
|
||||
exports.isDate = isDate;
|
||||
|
||||
function isError(e) {
|
||||
return isObject(e) &&
|
||||
(objectToString(e) === '[object Error]' || e instanceof Error);
|
||||
}
|
||||
exports.isError = isError;
|
||||
|
||||
function isFunction(arg) {
|
||||
return typeof arg === 'function';
|
||||
}
|
||||
exports.isFunction = isFunction;
|
||||
|
||||
function isPrimitive(arg) {
|
||||
return arg === null ||
|
||||
typeof arg === 'boolean' ||
|
||||
typeof arg === 'number' ||
|
||||
typeof arg === 'string' ||
|
||||
typeof arg === 'symbol' || // ES6 symbol
|
||||
typeof arg === 'undefined';
|
||||
}
|
||||
exports.isPrimitive = isPrimitive;
|
||||
|
||||
exports.isBuffer = require('./support/isBuffer');
|
||||
|
||||
function objectToString(o) {
|
||||
return Object.prototype.toString.call(o);
|
||||
}
|
||||
|
||||
|
||||
function pad(n) {
|
||||
return n < 10 ? '0' + n.toString(10) : n.toString(10);
|
||||
}
|
||||
|
||||
|
||||
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep',
|
||||
'Oct', 'Nov', 'Dec'];
|
||||
|
||||
// 26 Feb 16:19:34
|
||||
function timestamp() {
|
||||
var d = new Date();
|
||||
var time = [pad(d.getHours()),
|
||||
pad(d.getMinutes()),
|
||||
pad(d.getSeconds())].join(':');
|
||||
return [d.getDate(), months[d.getMonth()], time].join(' ');
|
||||
}
|
||||
|
||||
|
||||
// log is just a thin wrapper to console.log that prepends a timestamp
|
||||
exports.log = function() {
|
||||
console.log('%s - %s', timestamp(), exports.format.apply(exports, arguments));
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Inherit the prototype methods from one constructor into another.
|
||||
*
|
||||
* The Function.prototype.inherits from lang.js rewritten as a standalone
|
||||
* function (not on Function.prototype). NOTE: If this file is to be loaded
|
||||
* during bootstrapping this function needs to be rewritten using some native
|
||||
* functions as prototype setup using normal JavaScript does not work as
|
||||
* expected during bootstrapping (see mirror.js in r114903).
|
||||
*
|
||||
* @param {function} ctor Constructor function which needs to inherit the
|
||||
* prototype.
|
||||
* @param {function} superCtor Constructor function to inherit prototype from.
|
||||
*/
|
||||
exports.inherits = require('inherits');
|
||||
|
||||
exports._extend = function(origin, add) {
|
||||
// Don't do anything if add isn't an object
|
||||
if (!add || !isObject(add)) return origin;
|
||||
|
||||
var keys = Object.keys(add);
|
||||
var i = keys.length;
|
||||
while (i--) {
|
||||
origin[keys[i]] = add[keys[i]];
|
||||
}
|
||||
return origin;
|
||||
};
|
||||
|
||||
function hasOwnProperty(obj, prop) {
|
||||
return Object.prototype.hasOwnProperty.call(obj, prop);
|
||||
}
|
||||
Generated
+1
-43
@@ -10,9 +10,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"fs": "0.0.1-security",
|
||||
"node-fetch": "^3.3.2",
|
||||
"path": "^0.12.7"
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
@@ -346,12 +344,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs": {
|
||||
"version": "0.0.1-security",
|
||||
"resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz",
|
||||
"integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -638,16 +630,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path": {
|
||||
"version": "0.12.7",
|
||||
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
|
||||
"integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"process": "^0.11.1",
|
||||
"util": "^0.10.3"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
|
||||
@@ -657,15 +639,6 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -916,21 +889,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
|
||||
"integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/util/node_modules/inherits": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
||||
+1
-3
@@ -24,8 +24,6 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"fs": "0.0.1-security",
|
||||
"node-fetch": "^3.3.2",
|
||||
"path": "^0.12.7"
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
/* Main grid layout for analytics dashboard */
|
||||
.analytics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.25rem;
|
||||
margin-top: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(250px, 100vw), 1fr));
|
||||
gap: clamp(0.75rem, 2vw, 1.5rem);
|
||||
margin-top: clamp(0.75rem, 2vw, 1.5rem);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -3,8 +3,9 @@
|
||||
/* Badge Celebration - User Game View */
|
||||
.badge-celebration {
|
||||
text-align: center;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
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%);
|
||||
|
||||
@@ -249,9 +249,9 @@
|
||||
/* User profile stats */
|
||||
.user-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(200px, 100vw), 1fr));
|
||||
gap: clamp(0.5rem, 2vw, 1.5rem);
|
||||
margin-bottom: clamp(1rem, 2vw, 1.5rem);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
/* Handwriting Recognition Styles */
|
||||
|
||||
/* Canvas container */
|
||||
.handwriting-container {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 2px solid var(--border);
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.handwriting-container.active {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Canvas element */
|
||||
#handwritingCanvas {
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
cursor: crosshair;
|
||||
display: block;
|
||||
margin: 1rem auto;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
touch-action: none; /* Prevent scrolling on touch devices */
|
||||
}
|
||||
|
||||
#handwritingCanvas:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1), 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Canvas controls */
|
||||
.canvas-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.canvas-controls button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.canvas-controls .clear-btn {
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.canvas-controls .clear-btn:hover {
|
||||
background-color: #dc2626;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.canvas-controls .recognize-btn {
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.canvas-controls .recognize-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.canvas-controls .recognize-btn:disabled {
|
||||
background: var(--text-secondary);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.canvas-controls .undo-btn {
|
||||
background-color: var(--warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.canvas-controls .undo-btn:hover {
|
||||
background-color: #d97706;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Input mode toggle */
|
||||
.input-mode-toggle {
|
||||
display: flex;
|
||||
background: var(--secondary);
|
||||
border-radius: 12px;
|
||||
padding: 0.25rem;
|
||||
margin: 1.5rem auto;
|
||||
max-width: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-mode-toggle button {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.input-mode-toggle button.active {
|
||||
background: var(--bg-primary);
|
||||
color: var(--primary);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.input-mode-toggle button:not(.active):hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Recognition status */
|
||||
.recognition-status {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.recognition-status.processing {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
border: 1px solid #fcd34d;
|
||||
}
|
||||
|
||||
.recognition-status.success {
|
||||
background-color: #f0fdf4;
|
||||
color: var(--success);
|
||||
border: 1px solid var(--success);
|
||||
}
|
||||
|
||||
.recognition-status.error {
|
||||
background-color: #fef2f2;
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error);
|
||||
}
|
||||
|
||||
.recognition-status.empty {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Recognized text display */
|
||||
.recognized-text {
|
||||
background: var(--secondary);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary);
|
||||
border: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.recognized-text.has-content {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Handwriting hints */
|
||||
.handwriting-hints {
|
||||
background: var(--secondary);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.handwriting-hints h4 {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.handwriting-hints ul {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.handwriting-hints li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Stroke width selector */
|
||||
.stroke-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.stroke-controls label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stroke-width-slider {
|
||||
width: 100px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--border);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.stroke-width-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.stroke-width-slider::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* Color picker */
|
||||
.color-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-picker:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Tablet-specific handwriting styles */
|
||||
@media screen and (min-width: 768px) and (max-width: 1024px) {
|
||||
.handwriting-container {
|
||||
padding: 2rem;
|
||||
margin: 2rem auto;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
#handwritingCanvas {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.canvas-controls {
|
||||
gap: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.canvas-controls button {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
min-height: 50px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.input-mode-toggle {
|
||||
max-width: 400px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.input-mode-toggle button {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.recognized-text {
|
||||
font-size: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.handwriting-hints {
|
||||
padding: 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.handwriting-hints h4 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile handwriting adjustments */
|
||||
@media screen and (max-width: 767px) {
|
||||
.handwriting-container {
|
||||
margin: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
#handwritingCanvas {
|
||||
width: 100%;
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.canvas-controls {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.canvas-controls button {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.input-mode-toggle {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.stroke-controls,
|
||||
.color-controls {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation for canvas drawing */
|
||||
@keyframes drawStroke {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.drawing-animation {
|
||||
animation: drawStroke 0.1s ease-out;
|
||||
}
|
||||
|
||||
/* Loading spinner for recognition */
|
||||
.recognition-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility improvements */
|
||||
.handwriting-container:focus-within {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.canvas-controls button:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
#handwritingCanvas {
|
||||
border-width: 3px;
|
||||
border-color: #000;
|
||||
}
|
||||
|
||||
.canvas-controls button {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.recognition-status {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
+368
-58
@@ -2,17 +2,40 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<title>✨ Modern Spelling Practice</title>
|
||||
<link href="styles.css" rel="stylesheet"/>
|
||||
<link href="mobile.css" rel="stylesheet"/>
|
||||
<link href="tablet.css" rel="stylesheet"/>
|
||||
<link href="tablet-menu.css" rel="stylesheet"/>
|
||||
<link href="handwriting.css" rel="stylesheet"/>
|
||||
<link href="native-recognition.css" rel="stylesheet"/>
|
||||
<link href="unified-write.css" rel="stylesheet"/>
|
||||
<link href="tab-contrast.css" rel="stylesheet"/>
|
||||
<link href="badges.css" rel="stylesheet"/>
|
||||
<link href="analytics.css" rel="stylesheet"/>
|
||||
<link href="gamification.css" rel="stylesheet"/>
|
||||
<link href="spaced-repetition.css" rel="stylesheet"/>
|
||||
<!-- 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/3.3.1/sha.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<!-- TensorFlow.js for better handwriting recognition -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.10.0/dist/tf.min.js"></script>
|
||||
<!-- Fallback for jsSHA library if CDN fails -->
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
if (typeof jsSHA === 'undefined') {
|
||||
console.warn('jsSHA not loaded from CDN, using fallback');
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/jssha@3.3.0/dist/sha.js';
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
console.log('jsSHA loaded successfully');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/ui.js"></script>
|
||||
<script src="js/themes.js"></script>
|
||||
@@ -27,6 +50,12 @@
|
||||
<script src="js/analytics.js"></script>
|
||||
<script src="js/gamification.js"></script>
|
||||
<script src="js/spaced-repetition.js"></script>
|
||||
<script src="js/native-handwriting.js"></script>
|
||||
<script src="js/handwriting.js"></script>
|
||||
<script src="js/unified-write.js"></script>
|
||||
<script src="js/tablet-menu.js"></script>
|
||||
<script src="js/tablet-utils.js"></script>
|
||||
<script src="js/draggable-cards.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
<!-- Initialization script - loads last -->
|
||||
<script src="js/chart-safety.js"></script>
|
||||
@@ -57,50 +86,245 @@
|
||||
<p id="loginMessage" style="margin-top: 1rem; text-align: center;"></p>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<!-- Enhanced Navigation Menu -->
|
||||
<div id="nav" class="hidden">
|
||||
<p><strong id="userInfo"></strong></p>
|
||||
<div class="flex gap-4 justify-center" style="margin-top: 1rem;">
|
||||
<button class="hidden" id="adminBtn" onclick="showAdmin()">👨💼 Admin Panel</button>
|
||||
<button class="hidden" id="studentBtn" onclick="showStudent()">🎓 Student Panel</button>
|
||||
<button id="logoutBtn" onclick="logoutUser()" class="btn-secondary">Sign Out</button>
|
||||
<div class="nav-header">
|
||||
<p><strong id="userInfo"></strong></p>
|
||||
<div class="user-badge" id="userBadge"></div>
|
||||
</div>
|
||||
|
||||
<!-- Student Menu Items -->
|
||||
<div id="studentNavItems" class="nav-section hidden">
|
||||
<h4>🎓 Activities</h4>
|
||||
<div class="nav-grid">
|
||||
<button onclick="startGame(); closeMenuIfTablet();" class="nav-activity-btn">
|
||||
<span class="nav-icon">🎮</span>
|
||||
<div>
|
||||
<div class="nav-title">Spelling Game</div>
|
||||
<div class="nav-subtitle">Interactive challenges</div>
|
||||
</div>
|
||||
</button>
|
||||
<button onclick="startTypingPractice(); closeMenuIfTablet();" class="nav-activity-btn">
|
||||
<span class="nav-icon">⌨️</span>
|
||||
<div>
|
||||
<div class="nav-title">Typing Practice</div>
|
||||
<div class="nav-subtitle">Improve speed & accuracy</div>
|
||||
</div>
|
||||
</button>
|
||||
<button onclick="startBibleTyping(); closeMenuIfTablet();" class="nav-activity-btn">
|
||||
<span class="nav-icon">📖</span>
|
||||
<div>
|
||||
<div class="nav-title">Bible Typing</div>
|
||||
<div class="nav-subtitle">Spiritual practice</div>
|
||||
</div>
|
||||
</button>
|
||||
<button onclick="startSpacedReview(); closeMenuIfTablet();" class="nav-activity-btn">
|
||||
<span class="nav-icon">🔄</span>
|
||||
<div>
|
||||
<div class="nav-title">Spaced Review</div>
|
||||
<div class="nav-subtitle">Smart learning</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h4>📊 Progress</h4>
|
||||
<div class="nav-list">
|
||||
<button onclick="showStudent(); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">📈</span>Dashboard
|
||||
</button>
|
||||
<button onclick="showChallenges(); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">🎯</span>Challenges
|
||||
</button>
|
||||
<button onclick="showLeaderboards(); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">🏆</span>Leaderboards
|
||||
</button>
|
||||
<button onclick="showBadges(); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">🏅</span>My Badges
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h4>⚙️ Settings</h4>
|
||||
<div class="nav-list">
|
||||
<button onclick="showThemeSelector(); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">🎨</span>Themes
|
||||
</button>
|
||||
<button onclick="showInputSettings(); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">✏️</span>Input Method
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Menu Items -->
|
||||
<div id="adminNavItems" class="nav-section hidden">
|
||||
<h4>👨💼 Administration</h4>
|
||||
<div class="nav-list">
|
||||
<button onclick="showAdmin(); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">🏠</span>Dashboard
|
||||
</button>
|
||||
<button onclick="showAdminTab('tabUsers'); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">👤</span>User Management
|
||||
</button>
|
||||
<button onclick="showAdminTab('tabWords'); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">📝</span>Word Lists
|
||||
</button>
|
||||
<button onclick="showAdminTab('tabAnalytics'); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">📊</span>Analytics
|
||||
</button>
|
||||
<button onclick="showAdminTab('tabResults'); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">📋</span>Results
|
||||
</button>
|
||||
<button onclick="showAdminTab('tabReports'); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">📘</span>Reports
|
||||
</button>
|
||||
<button onclick="showAdminTab('tabSessions'); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">📅</span>Sessions
|
||||
</button>
|
||||
<button onclick="showAdminTab('tabBadges'); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">�</span>Badge System
|
||||
</button>
|
||||
<button onclick="showAdminTab('tabPasswords'); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">🔐</span>Passwords
|
||||
</button>
|
||||
<button onclick="showAdminTab('tabTheme'); closeMenuIfTablet();" class="nav-list-btn">
|
||||
<span class="nav-icon">🎨</span>Themes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Common Actions -->
|
||||
<div class="nav-section nav-footer">
|
||||
<button id="logoutBtn" onclick="logoutUser()" class="nav-logout-btn">
|
||||
<span class="nav-icon">🚪</span>Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Panel -->
|
||||
<div class="hidden" id="adminPanel">
|
||||
<div class="card-header">
|
||||
<h2>👨💼 Admin Dashboard</h2>
|
||||
<p style="color: var(--text-secondary);">Manage users, word lists, and view analytics</p>
|
||||
<!-- Admin Dashboard Overview -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<h2>👨💼 Admin Dashboard</h2>
|
||||
<p style="color: var(--text-secondary); margin: 0;">System overview and quick access to admin functions</p>
|
||||
</div>
|
||||
<button id="refreshDashboardBtn" class="btn btn-secondary" onclick="loadAdminDashboardStats()" title="Refresh statistics">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Stats Overview -->
|
||||
<div class="admin-overview">
|
||||
<div class="overview-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">👥</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number" id="totalUsers">-</div>
|
||||
<div class="stat-label">Total Users</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📝</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number" id="totalWordLists">-</div>
|
||||
<div class="stat-label">Word Lists</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🎯</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number" id="totalSessions">-</div>
|
||||
<div class="stat-label">Active Sessions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🏆</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number" id="totalBadges">-</div>
|
||||
<div class="stat-label">Badges Awarded</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Updated Timestamp -->
|
||||
<div style="text-align: center; margin-top: 1rem; opacity: 0.7;">
|
||||
<small id="lastUpdated">Last updated: Loading...</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Bubble for Admin Functions -->
|
||||
<div class="help-bubble-container">
|
||||
<button class="help-bubble" id="adminHelpBubble" title="View admin functions guide">
|
||||
<span class="help-icon">?</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden Menu Instructions (shown in popup) -->
|
||||
<div class="menu-instructions hidden" id="adminHelpContent">
|
||||
<h3>📱 Access Admin Functions</h3>
|
||||
<p>Use the menu button (<strong>☰</strong>) in the top-right corner to access all admin functions:</p>
|
||||
<div class="instruction-grid">
|
||||
<div class="instruction-item">
|
||||
<span class="instruction-icon">👤</span>
|
||||
<span><strong>User Management</strong> - Add/remove users</span>
|
||||
</div>
|
||||
<div class="instruction-item">
|
||||
<span class="instruction-icon">📝</span>
|
||||
<span><strong>Word Lists</strong> - Manage spelling lists</span>
|
||||
</div>
|
||||
<div class="instruction-item">
|
||||
<span class="instruction-icon">📊</span>
|
||||
<span><strong>Analytics</strong> - View detailed statistics</span>
|
||||
</div>
|
||||
<div class="instruction-item">
|
||||
<span class="instruction-icon">📋</span>
|
||||
<span><strong>Results</strong> - Check student performance</span>
|
||||
</div>
|
||||
<div class="instruction-item">
|
||||
<span class="instruction-icon">📘</span>
|
||||
<span><strong>Reports</strong> - Generate reports</span>
|
||||
</div>
|
||||
<div class="instruction-item">
|
||||
<span class="instruction-icon">⚙️</span>
|
||||
<span><strong>Settings</strong> - System configuration</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin Tabs -->
|
||||
<div id="adminTabs">
|
||||
<button class="active" data-tab="tabUsers" data-emoji="👤">Users</button>
|
||||
<button data-tab="tabWords" data-emoji="📝">Word Lists</button>
|
||||
<button data-tab="tabAnalytics" data-emoji="📊">Analytics</button>
|
||||
<button data-tab="tabResults" data-emoji="📋">Results</button>
|
||||
<button data-tab="tabReports" data-emoji="📘">Reports</button>
|
||||
<button data-tab="tabSessions" data-emoji="📅">Sessions</button>
|
||||
<button data-tab="tabBadges" data-emoji="🏅">Badges</button>
|
||||
<button data-tab="tabPasswords" data-emoji="🔐">Passwords</button>
|
||||
<button data-tab="tabTheme" data-emoji="🎨">Themes</button>
|
||||
<!-- Hidden Admin Tabs - Accessed via Menu -->
|
||||
<div style="display: none;">
|
||||
<div id="adminTabs">
|
||||
<button class="active" data-tab="tabUsers" data-emoji="👤">Users</button>
|
||||
<button data-tab="tabWords" data-emoji="📝">Word Lists</button>
|
||||
<button data-tab="tabAnalytics" data-emoji="📊">Analytics</button>
|
||||
<button data-tab="tabResults" data-emoji="📋">Results</button>
|
||||
<button data-tab="tabReports" data-emoji="📘">Reports</button>
|
||||
<button data-tab="tabSessions" data-emoji="📅">Sessions</button>
|
||||
<button data-tab="tabBadges" data-emoji="🏅">Badges</button>
|
||||
<button data-tab="tabPasswords" data-emoji="🔐">Passwords</button>
|
||||
<button data-tab="tabTheme" data-emoji="🎨">Themes</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab -->
|
||||
<div id="tabUsers" class="adminTab">
|
||||
<h3>👤 User Management</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-top: 1.5rem;">
|
||||
<div class="admin-grid">
|
||||
<div class="card">
|
||||
<h4>Add New User</h4>
|
||||
<form onsubmit="event.preventDefault(); addNewUser();" style="display: flex; flex-direction: column; gap: 1rem;">
|
||||
<form onsubmit="event.preventDefault(); addNewUser();" class="form-group">
|
||||
<input id="newUsername" placeholder="Username" required type="text"/>
|
||||
<input id="newPassword" placeholder="Password" required type="password"/>
|
||||
<select id="newRole">
|
||||
<option value="student">🎓 Student</option>
|
||||
<option value="admin">👨💼 Admin</option>
|
||||
</select>
|
||||
<button type="submit" class="btn-success">➕ Add User</button>
|
||||
<div class="btn-group">
|
||||
<button type="submit" class="btn-success">➕ Add User</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="userAddMessage" style="margin-top: 1rem;"></p>
|
||||
<small id="hashPreview" style="color: var(--text-secondary);"></small>
|
||||
@@ -116,7 +340,7 @@
|
||||
|
||||
<!-- Word Lists Tab -->
|
||||
<div class="adminTab hidden" id="tabWords">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
|
||||
<div class="admin-grid">
|
||||
<div class="card">
|
||||
<h4>Select User</h4>
|
||||
<select class="word-user-dropdown" id="wordUserSelect" onchange="loadWordsForSelectedUser()" style="width: 100%;">
|
||||
@@ -150,7 +374,10 @@
|
||||
<h4>Current Word List</h4>
|
||||
<ul id="wordListDisplay" style="max-height: 200px; overflow-y: auto;"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View by Student - Separate section -->
|
||||
<div class="admin-grid" style="margin-top: 2rem;">
|
||||
<div class="card">
|
||||
<h4>View by Student</h4>
|
||||
<select class="word-user-dropdown" id="viewUserSelect" onchange="displayWordListForSelectedUser()" style="width: 100%;">
|
||||
@@ -468,7 +695,7 @@
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>🎓 Student Dashboard</h2>
|
||||
<p style="color: var(--text-secondary);">Choose an activity to start practicing</p>
|
||||
<p style="color: var(--text-secondary);">Your learning journey starts here</p>
|
||||
</div>
|
||||
|
||||
<!-- Streak & User Stats Section -->
|
||||
@@ -477,29 +704,66 @@
|
||||
<div id="userStatsContainer" class="card dashboard-card"></div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Selection for Students -->
|
||||
<div class="card" style="margin-bottom: 1.5rem;">
|
||||
<h4>🎨 Choose Your Theme</h4>
|
||||
<select id="studentThemeSelect" onchange="changeTheme(this.value)" style="width: 100%; max-width: 300px;">
|
||||
<option value="default">✨ Modern (Default)</option>
|
||||
<option value="playground">🎪 Playground</option>
|
||||
<option value="chalkboard">🎓 Chalkboard</option>
|
||||
<option value="galaxy">🌌 Galaxy</option>
|
||||
<option value="nature">🌿 Nature</option>
|
||||
</select>
|
||||
<!-- Quick Start Section -->
|
||||
<div class="quick-start-section">
|
||||
<h3>🚀 Quick Start</h3>
|
||||
<p>Jump right into your favorite activity:</p>
|
||||
<div class="quick-actions-grid">
|
||||
<button onclick="startGame()" class="quick-action-card">
|
||||
<span class="quick-icon">🎮</span>
|
||||
<span class="quick-title">Spelling Game</span>
|
||||
</button>
|
||||
<button onclick="startTypingPractice()" class="quick-action-card">
|
||||
<span class="quick-icon">⌨️</span>
|
||||
<span class="quick-title">Typing Practice</span>
|
||||
</button>
|
||||
<button onclick="startBibleTyping()" class="quick-action-card">
|
||||
<span class="quick-icon">📖</span>
|
||||
<span class="quick-title">Bible Typing</span>
|
||||
</button>
|
||||
<button onclick="startSpacedReview()" class="quick-action-card">
|
||||
<span class="quick-icon">🔄</span>
|
||||
<span class="quick-title">Spaced Review</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="student-controls">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; max-width: 900px; margin: 0 auto;">
|
||||
<button id="startGameBtn" onclick="startGame()" class="btn-success">
|
||||
🎮 Spelling Game
|
||||
</button>
|
||||
<button id="startTypingBtn" onclick="startTypingPractice()" class="btn-warning">
|
||||
⌨️ Typing Practice
|
||||
</button>
|
||||
<button id="startBibleBtn" onclick="startBibleTyping()" class="btn-primary">
|
||||
📖 Bible Typing
|
||||
</button>
|
||||
<!-- Menu Instructions for Students -->
|
||||
<div class="menu-instructions student-instructions">
|
||||
<h3>📱 Explore More Features</h3>
|
||||
<p>Use the menu button (<strong>☰</strong>) to access:</p>
|
||||
<div class="instruction-grid">
|
||||
<div class="instruction-item">
|
||||
<span class="instruction-icon">🎯</span>
|
||||
<span><strong>Challenges</strong> - Track your goals</span>
|
||||
</div>
|
||||
<div class="instruction-item">
|
||||
<span class="instruction-icon">🏆</span>
|
||||
<span><strong>Leaderboards</strong> - Compare progress</span>
|
||||
</div>
|
||||
<div class="instruction-item">
|
||||
<span class="instruction-icon">🏅</span>
|
||||
<span><strong>Badges</strong> - View achievements</span>
|
||||
</div>
|
||||
<div class="instruction-item">
|
||||
<span class="instruction-icon">🎨</span>
|
||||
<span><strong>Themes</strong> - Customize appearance</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden original content for backwards compatibility -->
|
||||
<div style="display: none;">
|
||||
<!-- Theme Selection for Students -->
|
||||
<div class="card" style="margin-bottom: 1.5rem;">
|
||||
<h4>🎨 Choose Your Theme</h4>
|
||||
<select id="studentThemeSelect" onchange="changeTheme(this.value)" style="width: 100%; max-width: 300px;">
|
||||
<option value="default">✨ Modern (Default)</option>
|
||||
<option value="playground">🎪 Playground</option>
|
||||
<option value="chalkboard">🎓 Chalkboard</option>
|
||||
<option value="galaxy">🌌 Galaxy</option>
|
||||
<option value="nature">🌿 Nature</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -556,16 +820,28 @@
|
||||
</div>
|
||||
<div class="word-box" id="wordBox"></div>
|
||||
<div class="hidden" id="inputSection" style="text-align: center;">
|
||||
<input id="userInput" placeholder="Type the word you saw..." type="text"
|
||||
style="max-width: 400px;
|
||||
color: black !important;
|
||||
background-color: white !important;
|
||||
border: 2px solid #333 !important;
|
||||
font-size: 18px !important;
|
||||
padding: 12px !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
text-shadow: none !important;"/>
|
||||
<!-- Unified Write Mode Container -->
|
||||
<div id="unifiedWriteContainer"></div>
|
||||
|
||||
<!-- Legacy input (hidden, for compatibility) -->
|
||||
<input id="userInput" type="hidden"/>
|
||||
|
||||
<!-- Recognized Text Display -->
|
||||
<div id="recognizedText" class="recognized-text">Draw to see recognized text here</div>
|
||||
|
||||
<!-- Handwriting Tips -->
|
||||
<div class="handwriting-hints">
|
||||
<h4>💡 Tips for better recognition:</h4>
|
||||
<ul>
|
||||
<li>Write clearly with consistent letter size</li>
|
||||
<li>Use print letters rather than cursive</li>
|
||||
<li>Write one word at a time</li>
|
||||
<li>Allow space between letters</li>
|
||||
<li>Press firmly but write smoothly</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onclick="submitAnswer()" class="btn-primary">Submit Answer</button>
|
||||
<div id="wordFeedback" style="margin-top: 10px; font-weight: bold; min-height: 24px;"></div>
|
||||
</div>
|
||||
@@ -615,8 +891,42 @@
|
||||
</button>
|
||||
</div>
|
||||
<div id="typingPrompt">Get ready to type...</div>
|
||||
|
||||
<!-- Input Mode Toggle for Typing Practice -->
|
||||
<div class="input-mode-toggle">
|
||||
<button class="active" data-mode="typing" onclick="toggleInputMode('typing')">
|
||||
⌨️ Type
|
||||
</button>
|
||||
<button data-mode="handwriting" onclick="toggleInputMode('handwriting')">
|
||||
✏️ Write
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 2rem 0;">
|
||||
<!-- Typing Input -->
|
||||
<input id="typingInput" type="text" style="max-width: 500px;"/>
|
||||
|
||||
<!-- Handwriting Input for Typing Practice -->
|
||||
<div id="typingHandwritingContainer" class="handwriting-container" style="display: none;">
|
||||
<h4>✏️ Write your response:</h4>
|
||||
|
||||
<!-- Canvas for handwriting in typing practice -->
|
||||
<canvas id="typingHandwritingCanvas" width="600" height="300" style="width: 100%; max-width: 600px; height: 250px; border: 3px solid var(--primary); border-radius: 12px; background: white; display: block; margin: 1rem auto;"></canvas>
|
||||
|
||||
<!-- Canvas Controls for typing practice -->
|
||||
<div class="canvas-controls">
|
||||
<button onclick="clearTypingHandwriting()" class="clear-btn">🗑️ Clear</button>
|
||||
<button onclick="recognizeTypingHandwriting()" class="recognize-btn">🔍 Recognize</button>
|
||||
<button onclick="undoTypingHandwritingStroke()" class="undo-btn">↶ Undo</button>
|
||||
</div>
|
||||
|
||||
<!-- Recognition Status for typing practice -->
|
||||
<div id="typingRecognitionStatus" class="recognition-status empty"></div>
|
||||
|
||||
<!-- Recognized Text Display for typing practice -->
|
||||
<div id="typingRecognizedText" class="recognized-text">Draw to see recognized text here</div>
|
||||
</div>
|
||||
|
||||
<button onclick="submitTyping()" class="btn-primary">Submit</button>
|
||||
</div>
|
||||
<p id="typingFeedback" style="text-align: center; font-weight: 600;"></p>
|
||||
|
||||
+32
-7
@@ -217,26 +217,51 @@ async function addNewUser() {
|
||||
return;
|
||||
}
|
||||
|
||||
const hash = hashPassword(password);
|
||||
if (hashPreview) hashPreview.textContent = hash;
|
||||
// Check if hashPassword function exists
|
||||
let hash;
|
||||
try {
|
||||
hash = hashPassword(password);
|
||||
console.log("✅ Password hashed successfully");
|
||||
if (hashPreview) hashPreview.textContent = hash;
|
||||
} catch (e) {
|
||||
console.error("❌ Error hashing password:", e);
|
||||
msg.textContent = "Error: Unable to hash password. Check console for details.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch('/addUser', {
|
||||
console.log(`🔄 Sending request to add user: ${username} (${role})`);
|
||||
|
||||
const response = await fetch('/addUser', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, hash, role })
|
||||
});
|
||||
|
||||
console.log(`📥 Server response: ${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const successText = await response.text();
|
||||
console.log(`✅ Success: ${successText}`);
|
||||
msg.textContent = `✅ User "${username}" added to server.`;
|
||||
|
||||
// Clear form
|
||||
document.getElementById("newUsername").value = '';
|
||||
document.getElementById("newPassword").value = '';
|
||||
if (hashPreview) hashPreview.textContent = '[auto]';
|
||||
|
||||
await loadUsers();
|
||||
displayUserDropdown();
|
||||
populateWordUserDropdown();
|
||||
// Refresh user lists
|
||||
await loadUsers();
|
||||
displayUserDropdown();
|
||||
if (typeof populateWordUserDropdown === 'function') {
|
||||
populateWordUserDropdown();
|
||||
}
|
||||
} catch (e) {
|
||||
msg.textContent = "❌ Failed to add user.";
|
||||
msg.textContent = `❌ Failed to add user: ${e.message}`;
|
||||
console.error("Add user error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-28
@@ -29,7 +29,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Main function to refresh analytics
|
||||
async function analyticsRefresh() {
|
||||
// This is the main implementation
|
||||
console.log('📊 Refreshing analytics data...');
|
||||
|
||||
try {
|
||||
// Update UI to show loading state
|
||||
updateLoadingState(true);
|
||||
@@ -51,7 +51,7 @@ async function analyticsRefresh() {
|
||||
// Update UI to show data is loaded
|
||||
updateLoadingState(false);
|
||||
|
||||
console.log('📊 Analytics data loaded successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error refreshing analytics:', error);
|
||||
updateLoadingState(false);
|
||||
@@ -92,19 +92,15 @@ function updateLoadingState(isLoading) {
|
||||
|
||||
// Populate user dropdown with available users
|
||||
async function populateUserDropdown() {
|
||||
console.log('🔄 Populating analytics user dropdown...');
|
||||
const userSelect = document.getElementById('analyticsUserSelect');
|
||||
if (!userSelect) {
|
||||
console.error('⚠️ Analytics user select dropdown not found in the DOM!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we can use the shared admin users data
|
||||
if (typeof window.adminUsers !== 'undefined' && Array.isArray(window.adminUsers) && window.adminUsers.length > 0) {
|
||||
console.log('✅ Using shared admin users data:', window.adminUsers.map(u => u.username));
|
||||
|
||||
// Clear existing options except the first one (All Users)
|
||||
console.log('Clearing existing options...');
|
||||
while (userSelect.options.length > 1) {
|
||||
userSelect.remove(1);
|
||||
}
|
||||
@@ -117,21 +113,16 @@ async function populateUserDropdown() {
|
||||
userSelect.appendChild(option);
|
||||
});
|
||||
|
||||
console.log(`✅ Added ${window.adminUsers.length} users to analytics dropdown`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we can't use shared data, fetch from server
|
||||
try {
|
||||
// Clear existing options except the first one (All Users)
|
||||
console.log('Clearing existing options...');
|
||||
while (userSelect.options.length > 1) {
|
||||
userSelect.remove(1);
|
||||
}
|
||||
|
||||
// Always fetch fresh users from server
|
||||
console.log("📥 Fetching users directly from server...");
|
||||
|
||||
// Add a loading option
|
||||
const loadingOption = document.createElement('option');
|
||||
loadingOption.value = "";
|
||||
@@ -140,7 +131,6 @@ async function populateUserDropdown() {
|
||||
userSelect.appendChild(loadingOption);
|
||||
|
||||
const response = await fetch('/getUsers');
|
||||
console.log("GET /getUsers response:", response.status, response.statusText);
|
||||
|
||||
// Remove loading option
|
||||
if (userSelect.contains(loadingOption)) {
|
||||
@@ -152,20 +142,15 @@ async function populateUserDropdown() {
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
console.log("Raw server response:", responseText.substring(0, 100) + (responseText.length > 100 ? '...' : ''));
|
||||
|
||||
let users = [];
|
||||
try {
|
||||
const allUsers = JSON.parse(responseText);
|
||||
console.log("Parsed users data:", allUsers);
|
||||
|
||||
if (Array.isArray(allUsers)) {
|
||||
users = allUsers.map(user => user.username);
|
||||
console.log(`✅ Loaded ${users.length} users from server:`, users);
|
||||
|
||||
if (users.length === 0) {
|
||||
console.warn("⚠️ Server returned empty users array!");
|
||||
|
||||
// Add a warning option
|
||||
const warningOption = document.createElement('option');
|
||||
warningOption.value = "";
|
||||
@@ -174,32 +159,27 @@ async function populateUserDropdown() {
|
||||
userSelect.appendChild(warningOption);
|
||||
}
|
||||
} else {
|
||||
console.error("❌ Server returned non-array data:", allUsers);
|
||||
throw new Error("Invalid user data format");
|
||||
}
|
||||
} catch (jsonError) {
|
||||
console.error("❌ Error parsing JSON response:", jsonError);
|
||||
throw jsonError;
|
||||
}
|
||||
|
||||
// Add users to dropdown
|
||||
console.log(`Adding ${users.length} users to dropdown`);
|
||||
users.forEach(username => {
|
||||
const option = document.createElement('option');
|
||||
option.value = username;
|
||||
option.textContent = username;
|
||||
userSelect.appendChild(option);
|
||||
});
|
||||
console.log('✅ User dropdown populated successfully');
|
||||
} catch (error) {
|
||||
console.error("❌ Error populating user dropdown:", error);
|
||||
console.error("Error populating user dropdown:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Render the entire analytics dashboard
|
||||
function renderAnalyticsDashboard() {
|
||||
if (!analyticsData) {
|
||||
console.warn('No analytics data available to render');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -443,7 +423,6 @@ function renderProgressChart(data) {
|
||||
|
||||
// Skip invalid coordinates
|
||||
if (isNaN(x) || isNaN(y)) {
|
||||
console.warn('Invalid coordinate for progress chart:', { x, y, point, i });
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -473,10 +452,8 @@ function renderProgressChart(data) {
|
||||
chartGroup.appendChild(areaPath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating area path:', error);
|
||||
// Silently handle area path creation errors
|
||||
}
|
||||
} else {
|
||||
console.warn('Unable to create valid path data for progress chart');
|
||||
}
|
||||
|
||||
// Add data points
|
||||
@@ -763,7 +740,7 @@ function renderProblemWordsList(data) {
|
||||
|
||||
const word = document.createElement('div');
|
||||
word.className = 'problem-word';
|
||||
word.textContent = wordItem.word || wordItem.word;
|
||||
word.textContent = wordItem.word;
|
||||
|
||||
const count = document.createElement('div');
|
||||
count.className = 'problem-count';
|
||||
|
||||
@@ -72,6 +72,14 @@ async function verifyLogin() {
|
||||
function logoutUser() {
|
||||
localStorage.removeItem('loggedInUser');
|
||||
|
||||
// Clear current user
|
||||
window.currentUser = null;
|
||||
|
||||
// Close tablet menu if open
|
||||
if (window.tabletMenuManager && window.tabletMenuManager.isMenuOpen()) {
|
||||
window.tabletMenuManager.forceCloseMenu();
|
||||
}
|
||||
|
||||
// Hide all panels
|
||||
document.getElementById('adminPanel')?.classList.add('hidden');
|
||||
document.getElementById('studentPanel')?.classList.add('hidden');
|
||||
|
||||
@@ -62,6 +62,10 @@ const bibleVerses = [
|
||||
{
|
||||
reference: "Matthew 6:33",
|
||||
text: "But seek first the kingdom of God and his righteousness, and all these things will be added to you."
|
||||
},
|
||||
{
|
||||
reference: "John 11:36",
|
||||
text: "Jesus wept."
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
/**
|
||||
* Draggable & Resizable Cards System
|
||||
* Allows admin cards to be dragged, dropped, and resized within the admin panel
|
||||
*/
|
||||
|
||||
class DraggableCardManager {
|
||||
constructor() {
|
||||
this.cards = [];
|
||||
this.draggedCard = null;
|
||||
this.startPos = { x: 0, y: 0 };
|
||||
this.startSize = { width: 0, height: 0 };
|
||||
this.isResizing = false;
|
||||
this.isDragging = false;
|
||||
this.gridContainer = null;
|
||||
this.savedLayouts = this.loadSavedLayouts();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Wait for DOM to be ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => this.setupDraggableCards());
|
||||
} else {
|
||||
this.setupDraggableCards();
|
||||
}
|
||||
|
||||
console.log('🎯 Draggable Cards Manager initialized');
|
||||
}
|
||||
|
||||
setupDraggableCards() {
|
||||
// Find all potential containers with cards
|
||||
const containers = [
|
||||
document.querySelector('.admin-grid'),
|
||||
document.querySelector('.container'),
|
||||
document.body
|
||||
];
|
||||
|
||||
// Use the first container found, or body as fallback
|
||||
this.gridContainer = containers.find(container => container && container.querySelector('.card')) || document.body;
|
||||
|
||||
if (!this.gridContainer) {
|
||||
console.log('No container with cards found, retrying in 1 second...');
|
||||
setTimeout(() => this.setupDraggableCards(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Make the container suitable for absolute positioning
|
||||
if (this.gridContainer.style.position !== 'relative' && this.gridContainer.style.position !== 'absolute') {
|
||||
this.gridContainer.style.position = 'relative';
|
||||
}
|
||||
this.gridContainer.style.minHeight = '400px';
|
||||
|
||||
// Setup all cards found anywhere on the page
|
||||
this.setupAllCards();
|
||||
|
||||
// Load saved layout
|
||||
this.applySavedLayout();
|
||||
|
||||
// Add control buttons
|
||||
this.addControlButtons();
|
||||
|
||||
// Watch for new cards being added dynamically
|
||||
this.setupCardObserver();
|
||||
|
||||
console.log('📋 Draggable cards setup complete for all pages');
|
||||
}
|
||||
|
||||
setupAllCards() {
|
||||
// Find all cards on the entire page, not just in one container
|
||||
const allCards = document.querySelectorAll('.card');
|
||||
|
||||
allCards.forEach((card, index) => {
|
||||
// Skip if already setup
|
||||
if (card.dataset.draggableSetup === 'true') return;
|
||||
|
||||
this.makeCardDraggable(card, index);
|
||||
this.makeCardResizable(card, index);
|
||||
this.addCardHeader(card, index);
|
||||
|
||||
card.dataset.draggableSetup = 'true';
|
||||
});
|
||||
|
||||
console.log(`🎯 Setup ${allCards.length} draggable cards`);
|
||||
}
|
||||
|
||||
setupCardObserver() {
|
||||
// Watch for new cards being added to the page
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// Check if the added node is a card or contains cards
|
||||
const cards = node.classList && node.classList.contains('card')
|
||||
? [node]
|
||||
: node.querySelectorAll ? node.querySelectorAll('.card') : [];
|
||||
|
||||
cards.forEach((card, index) => {
|
||||
if (card.dataset.draggableSetup !== 'true') {
|
||||
const allCards = document.querySelectorAll('.card');
|
||||
const cardIndex = Array.from(allCards).indexOf(card);
|
||||
this.makeCardDraggable(card, cardIndex);
|
||||
this.makeCardResizable(card, cardIndex);
|
||||
this.addCardHeader(card, cardIndex);
|
||||
card.dataset.draggableSetup = 'true';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
this.cardObserver = observer;
|
||||
}
|
||||
|
||||
autoArrangeCards() {
|
||||
const cards = this.freeformContainer.querySelectorAll('.card');
|
||||
const containerWidth = this.freeformContainer.clientWidth - 40; // Account for padding
|
||||
const cardWidth = 350; // Standard width for side-by-side
|
||||
const cardHeight = 300; // Standard height
|
||||
const gap = 20; // Gap between cards
|
||||
|
||||
const cardsPerRow = Math.floor(containerWidth / (cardWidth + gap));
|
||||
|
||||
cards.forEach((card, index) => {
|
||||
const row = Math.floor(index / cardsPerRow);
|
||||
const col = index % cardsPerRow;
|
||||
|
||||
const x = 20 + (col * (cardWidth + gap));
|
||||
const y = 20 + (row * (cardHeight + gap));
|
||||
|
||||
card.style.left = x + 'px';
|
||||
card.style.top = y + 'px';
|
||||
card.style.width = cardWidth + 'px';
|
||||
});
|
||||
|
||||
console.log(`📐 Auto-arranged ${cards.length} cards in ${cardsPerRow} columns`);
|
||||
}
|
||||
|
||||
setupCards() {
|
||||
const cards = this.gridContainer.querySelectorAll('.card');
|
||||
|
||||
cards.forEach((card, index) => {
|
||||
this.makeCardDraggable(card, index);
|
||||
this.makeCardResizable(card, index);
|
||||
this.addCardHeader(card, index);
|
||||
});
|
||||
}
|
||||
|
||||
makeCardDraggable(card, index) {
|
||||
// Add draggable handle
|
||||
const header = card.querySelector('.card-header') || this.createCardHeader(card);
|
||||
|
||||
header.style.cursor = 'move';
|
||||
header.addEventListener('mousedown', (e) => this.startDrag(e, card, index));
|
||||
header.addEventListener('touchstart', (e) => this.startDrag(e, card, index));
|
||||
|
||||
// Prevent text selection while dragging
|
||||
header.style.userSelect = 'none';
|
||||
header.style.webkitUserSelect = 'none';
|
||||
}
|
||||
|
||||
makeCardResizable(card, index) {
|
||||
// Add resize handles
|
||||
const resizeHandle = document.createElement('div');
|
||||
resizeHandle.className = 'resize-handle';
|
||||
resizeHandle.innerHTML = '⟲';
|
||||
resizeHandle.style.cssText = `
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: se-resize;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
border-radius: 4px 0 8px 0;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
`;
|
||||
|
||||
resizeHandle.addEventListener('mousedown', (e) => this.startResize(e, card, index));
|
||||
resizeHandle.addEventListener('touchstart', (e) => this.startResize(e, card, index));
|
||||
|
||||
card.style.position = 'relative';
|
||||
card.appendChild(resizeHandle);
|
||||
|
||||
// Show/hide resize handle on hover
|
||||
card.addEventListener('mouseenter', () => resizeHandle.style.opacity = '1');
|
||||
card.addEventListener('mouseleave', () => resizeHandle.style.opacity = '0.7');
|
||||
}
|
||||
|
||||
createCardHeader(card) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'card-header';
|
||||
header.style.cssText = `
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: -2rem -2rem 1rem -2rem;
|
||||
border-radius: 8px 8px 0 0;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.3s ease;
|
||||
`;
|
||||
|
||||
const title = card.querySelector('h4');
|
||||
const titleText = title ? title.textContent : `Card ${card.dataset.cardIndex || ''}`;
|
||||
header.innerHTML = `
|
||||
<span>${titleText}</span>
|
||||
<span class="drag-indicator" style="opacity: 0.7;">⋮⋮</span>
|
||||
`;
|
||||
|
||||
card.insertBefore(header, card.firstChild);
|
||||
if (title) title.style.display = 'none'; // Hide original title
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
addCardHeader(card, index) {
|
||||
card.dataset.cardIndex = index;
|
||||
|
||||
if (!card.querySelector('.card-header')) {
|
||||
this.createCardHeader(card);
|
||||
}
|
||||
}
|
||||
|
||||
startDrag(e, card, index) {
|
||||
// Only allow dragging if global drag mode is enabled
|
||||
if (!document.body.classList.contains('global-drag-mode')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
this.isDragging = true;
|
||||
this.draggedCard = card;
|
||||
|
||||
const clientX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
const rect = card.getBoundingClientRect();
|
||||
this.startPos = {
|
||||
x: clientX - rect.left,
|
||||
y: clientY - rect.top
|
||||
};
|
||||
|
||||
// Switch to absolute positioning
|
||||
const parentRect = card.offsetParent ? card.offsetParent.getBoundingClientRect() : { left: 0, top: 0 };
|
||||
card.style.position = 'absolute';
|
||||
card.style.left = rect.left - parentRect.left + 'px';
|
||||
card.style.top = rect.top - parentRect.top + 'px';
|
||||
card.style.width = rect.width + 'px';
|
||||
card.style.zIndex = '1000';
|
||||
card.style.transform = 'rotate(2deg)';
|
||||
card.style.boxShadow = '0 8px 32px rgba(0,0,0,0.3)';
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener('mousemove', this.handleDrag);
|
||||
document.addEventListener('touchmove', this.handleDrag);
|
||||
document.addEventListener('mouseup', this.stopDrag);
|
||||
document.addEventListener('touchend', this.stopDrag);
|
||||
|
||||
card.classList.add('dragging');
|
||||
}
|
||||
|
||||
handleDrag = (e) => {
|
||||
if (!this.isDragging || !this.draggedCard) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
const containerRect = this.freeformContainer ? this.freeformContainer.getBoundingClientRect() :
|
||||
(this.draggedCard.offsetParent ? this.draggedCard.offsetParent.getBoundingClientRect() : { left: 0, top: 0 });
|
||||
const newX = clientX - containerRect.left - this.startPos.x;
|
||||
const newY = clientY - containerRect.top - this.startPos.y;
|
||||
|
||||
// Keep within bounds of the container with some padding
|
||||
const container = this.freeformContainer || this.draggedCard.offsetParent || document.body;
|
||||
const maxX = container.clientWidth - this.draggedCard.offsetWidth - 10;
|
||||
const maxY = container.clientHeight - this.draggedCard.offsetHeight - 10;
|
||||
|
||||
this.draggedCard.style.left = Math.max(0, Math.min(maxX, newX)) + 'px';
|
||||
this.draggedCard.style.top = Math.max(0, Math.min(maxY, newY)) + 'px';
|
||||
}
|
||||
|
||||
stopDrag = (e) => {
|
||||
if (!this.isDragging || !this.draggedCard) return;
|
||||
|
||||
this.isDragging = false;
|
||||
|
||||
// Reset visual effects
|
||||
this.draggedCard.style.transform = '';
|
||||
this.draggedCard.style.zIndex = '';
|
||||
this.draggedCard.style.boxShadow = '';
|
||||
this.draggedCard.classList.remove('dragging');
|
||||
|
||||
// Save position
|
||||
this.saveCardLayout();
|
||||
|
||||
// Clean up event listeners
|
||||
document.removeEventListener('mousemove', this.handleDrag);
|
||||
document.removeEventListener('touchmove', this.handleDrag);
|
||||
document.removeEventListener('mouseup', this.stopDrag);
|
||||
document.removeEventListener('touchend', this.stopDrag);
|
||||
|
||||
this.draggedCard = null;
|
||||
}
|
||||
|
||||
startResize(e, card, index) {
|
||||
// Only allow resizing if global drag mode is enabled
|
||||
if (!document.body.classList.contains('global-drag-mode')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.isResizing = true;
|
||||
this.draggedCard = card;
|
||||
|
||||
const clientX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
this.startPos = { x: clientX, y: clientY };
|
||||
this.startSize = {
|
||||
width: card.offsetWidth,
|
||||
height: card.offsetHeight
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', this.handleResize);
|
||||
document.addEventListener('touchmove', this.handleResize);
|
||||
document.addEventListener('mouseup', this.stopResize);
|
||||
document.addEventListener('touchend', this.stopResize);
|
||||
|
||||
card.classList.add('resizing');
|
||||
}
|
||||
|
||||
handleResize = (e) => {
|
||||
if (!this.isResizing || !this.draggedCard) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
const deltaX = clientX - this.startPos.x;
|
||||
const deltaY = clientY - this.startPos.y;
|
||||
|
||||
const newWidth = Math.max(200, this.startSize.width + deltaX);
|
||||
const newHeight = Math.max(150, this.startSize.height + deltaY);
|
||||
|
||||
this.draggedCard.style.width = newWidth + 'px';
|
||||
this.draggedCard.style.height = newHeight + 'px';
|
||||
}
|
||||
|
||||
stopResize = (e) => {
|
||||
if (!this.isResizing) return;
|
||||
|
||||
this.isResizing = false;
|
||||
this.draggedCard.classList.remove('resizing');
|
||||
|
||||
// Save size
|
||||
this.saveCardLayout();
|
||||
|
||||
// Clean up event listeners
|
||||
document.removeEventListener('mousemove', this.handleResize);
|
||||
document.removeEventListener('touchmove', this.handleResize);
|
||||
document.removeEventListener('mouseup', this.stopResize);
|
||||
document.removeEventListener('touchend', this.stopResize);
|
||||
|
||||
this.draggedCard = null;
|
||||
}
|
||||
|
||||
saveCardLayout() {
|
||||
const layout = {};
|
||||
const cards = this.gridContainer.querySelectorAll('.card');
|
||||
|
||||
cards.forEach((card, index) => {
|
||||
layout[index] = {
|
||||
left: card.style.left,
|
||||
top: card.style.top,
|
||||
width: card.style.width,
|
||||
height: card.style.height,
|
||||
position: card.style.position
|
||||
};
|
||||
});
|
||||
|
||||
localStorage.setItem('adminCardLayout', JSON.stringify(layout));
|
||||
console.log('💾 Card layout saved');
|
||||
}
|
||||
|
||||
loadSavedLayouts() {
|
||||
try {
|
||||
const saved = localStorage.getItem('adminCardLayout');
|
||||
return saved ? JSON.parse(saved) : {};
|
||||
} catch (e) {
|
||||
console.warn('Could not load saved layouts:', e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
applySavedLayout() {
|
||||
const cards = this.gridContainer.querySelectorAll('.card');
|
||||
|
||||
cards.forEach((card, index) => {
|
||||
const savedLayout = this.savedLayouts[index];
|
||||
if (savedLayout) {
|
||||
Object.assign(card.style, savedLayout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addControlButtons() {
|
||||
// Check if controls already exist
|
||||
if (document.querySelector('.global-card-controls')) return;
|
||||
|
||||
// Check if hamburger menu exists and adjust position accordingly
|
||||
const menuToggle = document.querySelector('.menu-toggle');
|
||||
const topPosition = menuToggle ? '80px' : '20px';
|
||||
|
||||
const controlsDiv = document.createElement('div');
|
||||
controlsDiv.className = 'global-card-controls card-controls';
|
||||
controlsDiv.style.cssText = `
|
||||
position: fixed;
|
||||
top: ${topPosition};
|
||||
right: 20px;
|
||||
z-index: 900;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
background: rgba(255,255,255,0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
||||
border: 1px solid var(--border);
|
||||
max-width: 300px;
|
||||
flex-wrap: wrap;
|
||||
`;
|
||||
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.className = 'btn btn-primary';
|
||||
toggleBtn.innerHTML = '🎯 Enable Drag Mode';
|
||||
toggleBtn.style.fontSize = '0.9rem';
|
||||
toggleBtn.onclick = () => this.toggleDragMode(toggleBtn);
|
||||
|
||||
const resetBtn = document.createElement('button');
|
||||
resetBtn.className = 'btn btn-secondary';
|
||||
resetBtn.innerHTML = '🔄 Reset';
|
||||
resetBtn.style.fontSize = '0.9rem';
|
||||
resetBtn.onclick = () => this.resetLayout();
|
||||
|
||||
const saveBtn = document.createElement('button');
|
||||
saveBtn.className = 'btn btn-success';
|
||||
saveBtn.innerHTML = '💾 Save';
|
||||
saveBtn.style.fontSize = '0.9rem';
|
||||
saveBtn.onclick = () => this.saveCardLayout();
|
||||
|
||||
const arrangeBtn = document.createElement('button');
|
||||
arrangeBtn.className = 'btn btn-info';
|
||||
arrangeBtn.innerHTML = '📐 Auto-Arrange';
|
||||
arrangeBtn.style.fontSize = '0.9rem';
|
||||
arrangeBtn.onclick = () => {
|
||||
if (document.body.classList.contains('global-drag-mode') && this.freeformContainer) {
|
||||
this.autoArrangeCards();
|
||||
}
|
||||
};
|
||||
|
||||
const minimizeBtn = document.createElement('button');
|
||||
minimizeBtn.className = 'btn btn-secondary';
|
||||
minimizeBtn.innerHTML = '−';
|
||||
minimizeBtn.style.fontSize = '1.2rem';
|
||||
minimizeBtn.style.width = '32px';
|
||||
minimizeBtn.style.padding = '0.25rem';
|
||||
minimizeBtn.title = 'Minimize controls';
|
||||
minimizeBtn.onclick = () => this.toggleControls(controlsDiv, minimizeBtn);
|
||||
|
||||
controlsDiv.appendChild(toggleBtn);
|
||||
controlsDiv.appendChild(resetBtn);
|
||||
controlsDiv.appendChild(saveBtn);
|
||||
controlsDiv.appendChild(arrangeBtn);
|
||||
controlsDiv.appendChild(minimizeBtn);
|
||||
|
||||
document.body.appendChild(controlsDiv);
|
||||
|
||||
this.controlsDiv = controlsDiv;
|
||||
this.toggleBtn = toggleBtn;
|
||||
|
||||
console.log('🎛️ Global card controls added');
|
||||
}
|
||||
|
||||
toggleControls(controlsDiv, minimizeBtn) {
|
||||
const buttons = controlsDiv.querySelectorAll('.btn:not(:last-child)');
|
||||
const isMinimized = controlsDiv.dataset.minimized === 'true';
|
||||
|
||||
if (isMinimized) {
|
||||
// Expand
|
||||
buttons.forEach(btn => btn.style.display = 'inline-flex');
|
||||
minimizeBtn.innerHTML = '−';
|
||||
minimizeBtn.title = 'Minimize controls';
|
||||
controlsDiv.dataset.minimized = 'false';
|
||||
} else {
|
||||
// Minimize
|
||||
buttons.forEach(btn => btn.style.display = 'none');
|
||||
minimizeBtn.innerHTML = '⚙️';
|
||||
minimizeBtn.title = 'Expand controls';
|
||||
controlsDiv.dataset.minimized = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
toggleDragMode(button) {
|
||||
const isCurrentlyEnabled = document.body.classList.contains('global-drag-mode');
|
||||
|
||||
if (isCurrentlyEnabled) {
|
||||
// Disable drag mode
|
||||
document.body.classList.remove('global-drag-mode');
|
||||
button.innerHTML = '🎯 Enable Drag Mode';
|
||||
button.className = 'btn btn-primary';
|
||||
|
||||
// Return cards to their original containers if possible
|
||||
const cards = document.querySelectorAll('.card');
|
||||
cards.forEach(card => {
|
||||
// Reset positioning
|
||||
card.style.position = '';
|
||||
card.style.left = '';
|
||||
card.style.top = '';
|
||||
card.style.zIndex = '';
|
||||
card.style.width = '';
|
||||
card.style.minWidth = '';
|
||||
|
||||
// Move back to original container if it was in freeform container
|
||||
if (card.parentNode === this.freeformContainer) {
|
||||
const originalContainer = document.querySelector('.admin-grid') ||
|
||||
document.querySelector('.container') ||
|
||||
document.body;
|
||||
originalContainer.appendChild(card);
|
||||
}
|
||||
});
|
||||
|
||||
// Hide freeform container
|
||||
if (this.freeformContainer) {
|
||||
this.freeformContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Remove drag mode indicator
|
||||
const indicator = document.querySelector('.global-drag-indicator');
|
||||
if (indicator) indicator.remove();
|
||||
|
||||
console.log('🔒 Drag mode disabled - cards returned to normal layout');
|
||||
} else {
|
||||
// Enable drag mode
|
||||
document.body.classList.add('global-drag-mode');
|
||||
button.innerHTML = '🔒 Disable Drag Mode';
|
||||
button.className = 'btn btn-warning';
|
||||
|
||||
// Add drag mode indicator
|
||||
this.addDragModeIndicator();
|
||||
|
||||
// Create and show freeform container
|
||||
this.ensureFreeformContainer();
|
||||
this.freeformContainer.style.display = 'block';
|
||||
|
||||
// Convert all cards to absolute positioning with flexible layout
|
||||
const cards = document.querySelectorAll('.card');
|
||||
|
||||
cards.forEach((card, index) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
|
||||
// Move card to freeform container if not already there
|
||||
if (card.parentNode !== this.freeformContainer) {
|
||||
this.freeformContainer.appendChild(card);
|
||||
}
|
||||
|
||||
const containerRect = this.freeformContainer.getBoundingClientRect();
|
||||
|
||||
card.style.position = 'absolute';
|
||||
card.style.left = Math.max(10, (rect.left - containerRect.left)) + 'px';
|
||||
card.style.top = Math.max(10, (rect.top - containerRect.top)) + 'px';
|
||||
card.style.width = Math.min(400, Math.max(250, rect.width)) + 'px'; // Flexible width
|
||||
card.style.zIndex = '10';
|
||||
card.style.minWidth = '250px';
|
||||
card.style.maxWidth = '500px'; // Allow for side-by-side placement
|
||||
});
|
||||
|
||||
// Auto-arrange cards in a grid-like pattern initially
|
||||
this.autoArrangeCards();
|
||||
|
||||
console.log('🎯 Free-form drag mode enabled - cards can be placed side-by-side');
|
||||
}
|
||||
}
|
||||
|
||||
ensureFreeformContainer() {
|
||||
// Create or get the freeform positioning container
|
||||
if (!this.freeformContainer || !document.body.contains(this.freeformContainer)) {
|
||||
this.freeformContainer = document.createElement('div');
|
||||
this.freeformContainer.className = 'freeform-card-container';
|
||||
this.freeformContainer.style.cssText = `
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
// Find the main container to replace or append to
|
||||
const mainContainer = document.querySelector('.container') || document.body;
|
||||
if (mainContainer === document.body) {
|
||||
document.body.appendChild(this.freeformContainer);
|
||||
} else {
|
||||
mainContainer.parentNode.insertBefore(this.freeformContainer, mainContainer.nextSibling);
|
||||
}
|
||||
}
|
||||
return this.freeformContainer;
|
||||
}
|
||||
|
||||
addDragModeIndicator() {
|
||||
// Remove existing indicator
|
||||
const existing = document.querySelector('.global-drag-indicator');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'global-drag-indicator';
|
||||
indicator.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
z-index: 899;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||
`;
|
||||
indicator.innerHTML = '🎯 Free-Form Layout Active - Drag cards anywhere, place them side-by-side, resize as needed';
|
||||
|
||||
document.body.appendChild(indicator);
|
||||
}
|
||||
|
||||
resetLayout() {
|
||||
const cards = this.gridContainer.querySelectorAll('.card');
|
||||
|
||||
cards.forEach((card) => {
|
||||
card.style.position = '';
|
||||
card.style.left = '';
|
||||
card.style.top = '';
|
||||
card.style.width = '';
|
||||
card.style.height = '';
|
||||
});
|
||||
|
||||
localStorage.removeItem('adminCardLayout');
|
||||
console.log('🔄 Layout reset');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.draggableCardManager = new DraggableCardManager();
|
||||
});
|
||||
} else {
|
||||
window.draggableCardManager = new DraggableCardManager();
|
||||
}
|
||||
+104
-18
@@ -7,13 +7,49 @@ let typingWords = [];
|
||||
let isSpacedRepetitionMode = false;
|
||||
|
||||
// 📝 Check user's answer and move to next word regardless of correctness
|
||||
window.submitAnswer = function() {
|
||||
const input = document.getElementById('userInput').value.trim();
|
||||
window.submitAnswer = function(providedAnswer = null) {
|
||||
// Get input from unified write mode or legacy inputs
|
||||
let input = '';
|
||||
|
||||
// First check if answer was provided directly (from unified write mode)
|
||||
if (providedAnswer) {
|
||||
input = providedAnswer.trim();
|
||||
} else if (window.unifiedWriteMode) {
|
||||
// Get from unified write mode
|
||||
input = window.unifiedWriteMode.getText();
|
||||
if (!input) {
|
||||
alert('Please enter an answer first!');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Legacy fallback
|
||||
const typingInput = document.getElementById('userInput');
|
||||
const recognizedText = document.getElementById('recognizedText');
|
||||
const handwritingContainer = document.getElementById('handwritingContainer');
|
||||
|
||||
// Check which input mode is active
|
||||
if (handwritingContainer && handwritingContainer.style.display !== 'none') {
|
||||
// Handwriting mode - get recognized text
|
||||
input = recognizedText ? recognizedText.textContent.trim() : '';
|
||||
if (input === 'Draw to see recognized text here' || input === '') {
|
||||
alert('Please write something or switch to typing mode');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Typing mode - get typed input
|
||||
input = typingInput.value.trim();
|
||||
if (input === '') {
|
||||
alert('Please type something or switch to handwriting mode');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record the attempt
|
||||
results.push({
|
||||
word: currentWord,
|
||||
attempt: input
|
||||
attempt: input,
|
||||
inputMode: handwritingContainer && handwritingContainer.style.display !== 'none' ? 'handwriting' : 'typing'
|
||||
});
|
||||
|
||||
// Show feedback and move to next word regardless of correctness
|
||||
@@ -21,7 +57,7 @@ window.submitAnswer = function() {
|
||||
// Correct answer - add visual feedback
|
||||
const inputEl = document.getElementById('userInput');
|
||||
inputEl.style.borderColor = 'var(--success)';
|
||||
inputEl.style.backgroundColor = '#f0fdf4';
|
||||
inputEl.style.backgroundColor = 'var(--bg-secondary)';
|
||||
|
||||
// Add feedback text if element exists
|
||||
const feedbackEl = document.getElementById('wordFeedback');
|
||||
@@ -34,7 +70,7 @@ window.submitAnswer = function() {
|
||||
// Incorrect answer - show correct spelling but continue
|
||||
const inputEl = document.getElementById('userInput');
|
||||
inputEl.style.borderColor = 'var(--error)';
|
||||
inputEl.style.backgroundColor = '#fef2f2';
|
||||
inputEl.style.backgroundColor = 'var(--bg-secondary)';
|
||||
|
||||
// Add feedback text if element exists
|
||||
const feedbackEl = document.getElementById('wordFeedback');
|
||||
@@ -63,6 +99,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add keyboard support for unified write mode
|
||||
if (window.unifiedWriteMode) {
|
||||
document.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter' && document.activeElement === window.unifiedWriteMode.canvas) {
|
||||
submitAnswer();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let currentSentence = '';
|
||||
@@ -205,23 +250,64 @@ function showWord() {
|
||||
inputSection.classList.remove('hidden');
|
||||
inputSection.style.display = 'block';
|
||||
|
||||
// Reset and focus the input field with enhanced visibility
|
||||
const userInput = document.getElementById('userInput');
|
||||
userInput.value = '';
|
||||
// Reset input (unified write mode or legacy)
|
||||
if (window.unifiedWriteMode) {
|
||||
window.unifiedWriteMode.clearText();
|
||||
} else {
|
||||
// Legacy input reset
|
||||
const userInput = document.getElementById('userInput');
|
||||
if (userInput) {
|
||||
userInput.value = '';
|
||||
userInput.classList.add('visible-input');
|
||||
}
|
||||
|
||||
// Use the class-based approach instead of inline styles
|
||||
userInput.classList.add('visible-input');
|
||||
// Reset handwriting input
|
||||
if (window.clearHandwriting) {
|
||||
window.clearHandwriting();
|
||||
}
|
||||
|
||||
// Focus with a slight delay to ensure UI is ready
|
||||
// Reset recognition status and text
|
||||
const recognitionStatus = document.getElementById('recognitionStatus');
|
||||
const recognizedText = document.getElementById('recognizedText');
|
||||
if (recognitionStatus) {
|
||||
recognitionStatus.className = 'recognition-status empty';
|
||||
recognitionStatus.textContent = '';
|
||||
}
|
||||
if (recognizedText) {
|
||||
recognizedText.textContent = 'Draw to see recognized text here';
|
||||
recognizedText.className = 'recognized-text';
|
||||
}
|
||||
}
|
||||
|
||||
// Focus appropriate input
|
||||
setTimeout(() => {
|
||||
userInput.focus();
|
||||
// Try to insert a cursor by setting a selection range
|
||||
try {
|
||||
userInput.setSelectionRange(0, 0);
|
||||
} catch (e) {
|
||||
console.log('Could not set selection range:', e);
|
||||
if (window.unifiedWriteMode) {
|
||||
// Focus unified write mode
|
||||
window.unifiedWriteMode.focusCanvas();
|
||||
console.log('🎯 Unified write mode focused, ready for input');
|
||||
} else {
|
||||
// Legacy focus logic
|
||||
const userInput = document.getElementById('userInput');
|
||||
const handwritingContainer = document.getElementById('handwritingContainer');
|
||||
|
||||
if (handwritingContainer && handwritingContainer.style.display !== 'none') {
|
||||
// Handwriting mode - focus canvas
|
||||
const canvas = document.getElementById('handwritingCanvas');
|
||||
if (canvas) {
|
||||
canvas.focus();
|
||||
console.log('🎨 Canvas focused, ready for handwriting');
|
||||
}
|
||||
} else if (userInput) {
|
||||
// Typing mode - focus input
|
||||
userInput.focus();
|
||||
try {
|
||||
userInput.setSelectionRange(0, 0);
|
||||
} catch (e) {
|
||||
console.log('Could not set selection range:', e);
|
||||
}
|
||||
console.log('🔍 Input focused, ready for typing');
|
||||
}
|
||||
}
|
||||
console.log('🔍 Input focused, ready for typing');
|
||||
}, 50);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
// 🌍 Game state
|
||||
let words = [];
|
||||
let currentWord = '';
|
||||
let currentIndex = 0;
|
||||
let results = [];
|
||||
let typingWords = [];
|
||||
let isSpacedRepetitionMode = false;
|
||||
|
||||
// 📝 Check user's answer and move to next word regardless of correctness
|
||||
window.submitAnswer = function() {
|
||||
const input = document.getElementById('userInput').value.trim();
|
||||
|
||||
// Record the attempt
|
||||
results.push({
|
||||
word: currentWord,
|
||||
attempt: input
|
||||
});
|
||||
|
||||
// Show feedback and move to next word regardless of correctness
|
||||
if (input.toLowerCase() === currentWord.toLowerCase()) {
|
||||
// Correct answer - add visual feedback
|
||||
const inputEl = document.getElementById('userInput');
|
||||
inputEl.style.borderColor = 'var(--success)';
|
||||
inputEl.style.backgroundColor = '#f0fdf4';
|
||||
|
||||
// Add feedback text if element exists
|
||||
const feedbackEl = document.getElementById('wordFeedback');
|
||||
if (feedbackEl) {
|
||||
feedbackEl.textContent = '✓ Correct!';
|
||||
feedbackEl.className = 'feedback-correct';
|
||||
feedbackEl.style.color = 'var(--success)';
|
||||
}
|
||||
} else {
|
||||
// Incorrect answer - show correct spelling but continue
|
||||
const inputEl = document.getElementById('userInput');
|
||||
inputEl.style.borderColor = 'var(--error)';
|
||||
inputEl.style.backgroundColor = '#fef2f2';
|
||||
|
||||
// Add feedback text if element exists
|
||||
const feedbackEl = document.getElementById('wordFeedback');
|
||||
if (feedbackEl) {
|
||||
feedbackEl.textContent = `✗ Correct spelling: ${currentWord}`;
|
||||
feedbackEl.className = 'feedback-incorrect';
|
||||
feedbackEl.style.color = 'var(--error)';
|
||||
}
|
||||
}
|
||||
|
||||
// Always advance to next word
|
||||
currentIndex++;
|
||||
document.getElementById('inputSection').classList.add('hidden');
|
||||
|
||||
// Small delay before showing next word
|
||||
setTimeout(() => showWord(), 1500);
|
||||
}
|
||||
|
||||
// Add Enter key support for game input
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const gameInput = document.getElementById('userInput');
|
||||
if (gameInput) {
|
||||
gameInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
submitAnswer();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let currentSentence = '';
|
||||
|
||||
function showStudent() {
|
||||
console.log("🔊 Showing student panel");
|
||||
|
||||
// Hide admin panel
|
||||
document.getElementById('adminPanel')?.classList.add('hidden');
|
||||
|
||||
// Show student panel and its components
|
||||
const studentPanel = document.getElementById('studentPanel');
|
||||
studentPanel?.classList.remove('hidden');
|
||||
|
||||
// Get the buttons and explicitly set their properties
|
||||
const gameBtn = document.getElementById('startGameBtn');
|
||||
const typingBtn = document.getElementById('startTypingBtn');
|
||||
|
||||
if (gameBtn && typingBtn) {
|
||||
console.log("Found buttons, making visible");
|
||||
// Remove all possible hiding classes/styles
|
||||
gameBtn.classList.remove('hidden');
|
||||
typingBtn.classList.remove('hidden');
|
||||
|
||||
// Explicitly set display style
|
||||
gameBtn.style.cssText = 'display: inline-block !important; visibility: visible !important;';
|
||||
typingBtn.style.cssText = 'display: inline-block !important; visibility: visible !important;';
|
||||
} else {
|
||||
console.error("Could not find game buttons");
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 Begin game session
|
||||
window.startGame = async function(customWords = null, isSpacedRepetition = false) {
|
||||
console.log('🎮 Starting spelling game');
|
||||
|
||||
const user = JSON.parse(localStorage.getItem('loggedInUser'));
|
||||
if (!user) {
|
||||
alert('Please log in first');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Hide student dashboard and other sections
|
||||
document.getElementById('studentPanel').style.display = 'none';
|
||||
document.getElementById('typingSection').style.display = 'none';
|
||||
document.getElementById('bibleSection').style.display = 'none';
|
||||
|
||||
// Show game section
|
||||
document.getElementById('gameSection').classList.remove('hidden');
|
||||
document.getElementById('gameSection').style.display = 'block';
|
||||
document.getElementById('wordBox').textContent = 'Loading...';
|
||||
document.getElementById('summary')?.classList.add('hidden');
|
||||
document.getElementById('badgeDisplay')?.classList.add('hidden');
|
||||
|
||||
let wordList;
|
||||
|
||||
// Use custom words if provided (for spaced repetition)
|
||||
if (customWords && Array.isArray(customWords) && customWords.length > 0) {
|
||||
wordList = customWords;
|
||||
isSpacedRepetitionMode = isSpacedRepetition;
|
||||
console.log('Using custom word list for spaced repetition:', wordList);
|
||||
} else {
|
||||
// Fetch words from user's word list
|
||||
const response = await fetch(`/getWordList?username=${user.username}`);
|
||||
const data = await response.json();
|
||||
console.log('Received words:', data);
|
||||
|
||||
let weekWords = [];
|
||||
// Safely handle various response formats
|
||||
try {
|
||||
if (data && data.words && Array.isArray(data.words)) {
|
||||
// Server returns {words: [array]}
|
||||
weekWords = data.words;
|
||||
} else if (data && data.words && typeof data.words === 'object' && data.words.weeks && data.words.activeWeek) {
|
||||
// Server returns {words: {weeks: [], activeWeek: ""}}
|
||||
const active = data.words.activeWeek;
|
||||
const found = data.words.weeks.find(w => w.date === active);
|
||||
if (found && Array.isArray(found.words)) {
|
||||
weekWords = found.words;
|
||||
}
|
||||
} else if (Array.isArray(data)) {
|
||||
// Server returns direct array
|
||||
weekWords = data;
|
||||
} else if (typeof data === 'object' && Array.isArray(Object.values(data)[0])) {
|
||||
// Handle unexpected format as best we can
|
||||
weekWords = Object.values(data)[0];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error processing word list data:", err);
|
||||
}
|
||||
|
||||
wordList = weekWords.length > 0 ? weekWords : ["default", "practice", "words"];
|
||||
}
|
||||
|
||||
if (!wordList.length) {
|
||||
alert('No words found for practice');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start game
|
||||
words = [...wordList];
|
||||
typingWords = [...wordList]; // Initialize typing words too
|
||||
currentIndex = 0;
|
||||
results = []; // Reset results array
|
||||
showWord();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to load words');
|
||||
}
|
||||
}
|
||||
|
||||
function showWord() {
|
||||
if (currentIndex >= words.length) {
|
||||
showSummary();
|
||||
return;
|
||||
}
|
||||
|
||||
currentWord = words[currentIndex];
|
||||
console.log('Current word:', currentWord);
|
||||
|
||||
// Show word briefly then hide
|
||||
const wordBox = document.getElementById('wordBox');
|
||||
const inputSection = document.getElementById('inputSection');
|
||||
|
||||
// Reset any feedback text
|
||||
const feedbackEl = document.getElementById('wordFeedback');
|
||||
if (feedbackEl) {
|
||||
feedbackEl.textContent = '';
|
||||
feedbackEl.className = '';
|
||||
}
|
||||
|
||||
wordBox.textContent = currentWord;
|
||||
inputSection.classList.add('hidden');
|
||||
|
||||
setTimeout(() => {
|
||||
wordBox.textContent = '';
|
||||
inputSection.classList.remove('hidden');
|
||||
document.getElementById('userInput').value = '';
|
||||
document.getElementById('userInput').focus();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function showSummary() {
|
||||
console.log('Game completed, showing summary');
|
||||
document.getElementById('inputSection').classList.add('hidden');
|
||||
|
||||
// Calculate stats
|
||||
let correctCount = 0;
|
||||
const incorrectWords = [];
|
||||
|
||||
results.forEach(result => {
|
||||
if (result.attempt.toLowerCase() === result.word.toLowerCase()) {
|
||||
correctCount++;
|
||||
} else {
|
||||
incorrectWords.push(result);
|
||||
}
|
||||
});
|
||||
|
||||
const accuracy = Math.round((correctCount / results.length) * 100);
|
||||
|
||||
// Show summary
|
||||
document.getElementById('summary').classList.remove('hidden');
|
||||
document.getElementById('totalWords').textContent = results.length;
|
||||
document.getElementById('correctWords').textContent = correctCount;
|
||||
document.getElementById('accuracy').textContent = `${accuracy}%`;
|
||||
|
||||
// Show incorrect words
|
||||
const incorrectList = document.getElementById('incorrectList');
|
||||
incorrectList.innerHTML = '';
|
||||
|
||||
if (incorrectWords.length === 0) {
|
||||
incorrectList.innerHTML = '<li>Perfect! No mistakes.</li>';
|
||||
} else {
|
||||
incorrectWords.forEach(item => {
|
||||
incorrectList.innerHTML += `
|
||||
<li>
|
||||
<span class="word">${item.word}</span>
|
||||
<span class="attempt">${item.attempt}</span>
|
||||
</li>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
// Save results if not in spaced repetition mode
|
||||
if (!isSpacedRepetitionMode) {
|
||||
saveResults(results, accuracy);
|
||||
} else {
|
||||
console.log('In spaced repetition mode, not saving normal results');
|
||||
// Update spaced repetition data
|
||||
updateSpacedRepetitionResults(results);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveResults(resultData, accuracy) {
|
||||
try {
|
||||
const user = JSON.parse(localStorage.getItem('loggedInUser'));
|
||||
if (!user) return;
|
||||
|
||||
const payload = {
|
||||
username: user.username,
|
||||
results: resultData,
|
||||
accuracy,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const response = await fetch('/saveResults', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('Results saved successfully');
|
||||
checkForBadges(accuracy, resultData.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving results:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForBadges(accuracy, wordCount) {
|
||||
try {
|
||||
const user = JSON.parse(localStorage.getItem('loggedInUser'));
|
||||
if (!user) return;
|
||||
|
||||
// Check for earned badges
|
||||
const response = await fetch(`/checkBadges?username=${user.username}&accuracy=${accuracy}&wordCount=${wordCount}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.newBadges && data.newBadges.length > 0) {
|
||||
// Show badge notification
|
||||
showBadgeNotification(data.newBadges);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking badges:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showBadgeNotification(badges) {
|
||||
const badgeDisplay = document.getElementById('badgeDisplay');
|
||||
if (!badgeDisplay) return;
|
||||
|
||||
const badgeList = document.getElementById('badgeList');
|
||||
badgeList.innerHTML = '';
|
||||
|
||||
badges.forEach(badge => {
|
||||
badgeList.innerHTML += `
|
||||
<div class="badge-item">
|
||||
<div class="badge-icon">${badge.icon}</div>
|
||||
<div class="badge-info">
|
||||
<h4>${badge.name}</h4>
|
||||
<p>${badge.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
badgeDisplay.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Function to check if user has completed all words for today
|
||||
async function checkDailyCompletion() {
|
||||
try {
|
||||
const user = JSON.parse(localStorage.getItem('loggedInUser'));
|
||||
if (!user) return;
|
||||
|
||||
const response = await fetch(`/checkDailyCompletion?username=${user.username}`);
|
||||
const data = await response.json();
|
||||
|
||||
return data.completed;
|
||||
} catch (error) {
|
||||
console.error('Error checking daily completion:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Return to student panel
|
||||
window.returnToStudentPanel = function() {
|
||||
// Hide game and typing sections
|
||||
document.getElementById('gameSection').style.display = 'none';
|
||||
document.getElementById('typingSection').style.display = 'none';
|
||||
document.getElementById('bibleSection').style.display = 'none';
|
||||
|
||||
// Show student panel
|
||||
document.getElementById('studentPanel').style.display = 'block';
|
||||
|
||||
// Force panel to be visible
|
||||
showStudent();
|
||||
}
|
||||
@@ -167,12 +167,12 @@ async function completeChallenge(challengeId, challengeType) {
|
||||
// Render streak indicator in student panel
|
||||
function renderStreakIndicator() {
|
||||
const streakContainer = document.getElementById('streakContainer');
|
||||
if (!streakContainer || !userProgress) return;
|
||||
if (!streakContainer) return;
|
||||
|
||||
// Clear previous content
|
||||
streakContainer.innerHTML = '';
|
||||
|
||||
const currentStreak = userProgress.streaks.current || 0;
|
||||
const currentStreak = userProgress?.streaks?.current || 0;
|
||||
|
||||
// Create streak element
|
||||
const streakEl = document.createElement('div');
|
||||
@@ -191,7 +191,7 @@ function renderStreakIndicator() {
|
||||
</div>
|
||||
<div class="streak-record">
|
||||
<div class="streak-subtitle">Best</div>
|
||||
<div class="streak-best">${userProgress.streaks.longest || 0}</div>
|
||||
<div class="streak-best">${userProgress?.streaks?.longest || 0}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -201,7 +201,7 @@ function renderStreakIndicator() {
|
||||
// Render user stats in student panel
|
||||
function renderUserStats() {
|
||||
const statsContainer = document.getElementById('userStatsContainer');
|
||||
if (!statsContainer || !userProgress) return;
|
||||
if (!statsContainer) return;
|
||||
|
||||
// Clear previous content
|
||||
statsContainer.innerHTML = '';
|
||||
@@ -213,19 +213,19 @@ function renderUserStats() {
|
||||
// Add stat cards
|
||||
statsEl.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${userProgress.stats.points || 0}</div>
|
||||
<div class="stat-value">${userProgress?.stats?.points || 0}</div>
|
||||
<div class="stat-label">Points</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${userProgress.stats.accuracy ? userProgress.stats.accuracy.toFixed(1) + '%' : '0%'}</div>
|
||||
<div class="stat-value">${userProgress?.stats?.accuracy ? userProgress.stats.accuracy.toFixed(1) + '%' : '0%'}</div>
|
||||
<div class="stat-label">Accuracy</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${userProgress.stats.totalSessions || 0}</div>
|
||||
<div class="stat-value">${userProgress?.stats?.totalSessions || 0}</div>
|
||||
<div class="stat-label">Sessions</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${userProgress.stats.totalWords || 0}</div>
|
||||
<div class="stat-value">${userProgress?.stats?.totalWords || 0}</div>
|
||||
<div class="stat-label">Words</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -233,6 +233,21 @@ function renderUserStats() {
|
||||
statsContainer.appendChild(statsEl);
|
||||
}
|
||||
|
||||
// Initialize containers with default content
|
||||
function initializeStudentContainers() {
|
||||
// Initialize streak container immediately
|
||||
const streakContainer = document.getElementById('streakContainer');
|
||||
if (streakContainer && !streakContainer.innerHTML.trim()) {
|
||||
renderStreakIndicator();
|
||||
}
|
||||
|
||||
// Initialize stats container immediately
|
||||
const statsContainer = document.getElementById('userStatsContainer');
|
||||
if (statsContainer && !statsContainer.innerHTML.trim()) {
|
||||
renderUserStats();
|
||||
}
|
||||
}
|
||||
|
||||
// Render challenges in student panel
|
||||
function renderChallenges() {
|
||||
const challengesContainer = document.getElementById('challengesContainer');
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -24,12 +24,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
function fixInputVisibility() {
|
||||
const userInput = document.getElementById('userInput');
|
||||
if (!userInput) {
|
||||
console.log('❌ User input element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔧 Fixing input visibility');
|
||||
|
||||
// Create a style element for stronger CSS
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.textContent = `
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* Native Tablet Handwriting Recognition Integration
|
||||
* Leverages platform-specific handwriting APIs for better accuracy
|
||||
*/
|
||||
|
||||
class NativeHandwritingRecognition {
|
||||
constructor() {
|
||||
this.isSupported = false;
|
||||
this.recognizer = null;
|
||||
this.canvas = null;
|
||||
this.ctx = null;
|
||||
this.strokes = [];
|
||||
this.currentStroke = [];
|
||||
this.isDrawing = false;
|
||||
this.isAppleDevice = false;
|
||||
this.applePencilDetected = false;
|
||||
|
||||
// Initialize recognition capabilities
|
||||
this.initializeRecognition();
|
||||
}
|
||||
|
||||
async initializeRecognition() {
|
||||
console.log('🔍 Checking for native handwriting support...');
|
||||
|
||||
// Check for different platform APIs
|
||||
this.isSupported = await this.detectHandwritingSupport();
|
||||
|
||||
if (this.isSupported) {
|
||||
console.log('✅ Native handwriting recognition available');
|
||||
await this.setupRecognizer();
|
||||
} else {
|
||||
console.log('⚠️ Native handwriting not available, using fallback');
|
||||
}
|
||||
}
|
||||
|
||||
async detectHandwritingSupport() {
|
||||
console.log('🔍 Detecting handwriting support on:', navigator.userAgent);
|
||||
|
||||
// Detect Apple Pencil / iOS devices specifically
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const isSafari = navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome');
|
||||
|
||||
if (isIOS) {
|
||||
console.log('🍎 iOS device detected - optimizing for Apple Pencil');
|
||||
this.isAppleDevice = true;
|
||||
}
|
||||
|
||||
// Check for Web Handwriting API (experimental - Edge/Chrome)
|
||||
if ('createHandwritingRecognizer' in navigator) {
|
||||
console.log('🖋️ Web Handwriting API detected in', this.getBrowserInfo());
|
||||
|
||||
// Test if it actually works
|
||||
try {
|
||||
const testQuery = await navigator.queryHandwritingRecognizer({ languages: ['en'] });
|
||||
if (testQuery) {
|
||||
console.log('✅ Web Handwriting API is functional');
|
||||
return true;
|
||||
} else {
|
||||
console.log('⚠️ Web Handwriting API exists but query failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Web Handwriting API test failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Pointer Events API with pressure sensitivity (Windows tablets)
|
||||
if ('PointerEvent' in window) {
|
||||
console.log('👆 Pointer Events API detected - good for Windows tablets');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for Touch Events with force (iPad, Apple Pencil, etc.)
|
||||
if ('TouchEvent' in window || 'ontouchstart' in window) {
|
||||
console.log('📱 Touch Events API detected');
|
||||
if (isIOS) {
|
||||
console.log('🍎 Apple Pencil support likely available');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for Windows Ink API (UWP apps only)
|
||||
if (typeof Windows !== 'undefined' && Windows.UI && Windows.UI.Input && Windows.UI.Input.Inking) {
|
||||
console.log('🖊️ Windows Ink API detected (UWP context)');
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('⚠️ No native handwriting APIs detected');
|
||||
return false;
|
||||
}
|
||||
|
||||
getBrowserInfo() {
|
||||
const ua = navigator.userAgent;
|
||||
if (ua.includes('Edg/')) return 'Microsoft Edge';
|
||||
if (ua.includes('Chrome/')) return 'Google Chrome';
|
||||
if (ua.includes('Firefox/')) return 'Mozilla Firefox';
|
||||
if (ua.includes('Safari/') && !ua.includes('Chrome/')) return 'Safari';
|
||||
return 'Unknown Browser';
|
||||
}
|
||||
|
||||
async setupRecognizer() {
|
||||
try {
|
||||
// Try Web Handwriting API first (most accurate)
|
||||
if ('createHandwritingRecognizer' in navigator) {
|
||||
await this.setupWebHandwritingAPI();
|
||||
return;
|
||||
}
|
||||
|
||||
// Try Windows Ink API
|
||||
if (this.setupWindowsInkAPI()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to enhanced pointer/touch recognition
|
||||
this.setupEnhancedTouchRecognition();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to setup native recognition:', error);
|
||||
this.isSupported = false;
|
||||
}
|
||||
}
|
||||
|
||||
async setupWebHandwritingAPI() {
|
||||
try {
|
||||
// Configure the recognizer for English text
|
||||
const recognizerQuery = {
|
||||
languages: ['en'],
|
||||
alternatives: 3,
|
||||
inputType: 'text'
|
||||
};
|
||||
|
||||
// Check if the configuration is supported
|
||||
const queryResult = await navigator.queryHandwritingRecognizer(recognizerQuery);
|
||||
|
||||
if (queryResult) {
|
||||
this.recognizer = await navigator.createHandwritingRecognizer(recognizerQuery);
|
||||
console.log('✅ Web Handwriting API recognizer created');
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Web Handwriting API setup failed:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
setupWindowsInkAPI() {
|
||||
try {
|
||||
if ('Windows' in window && window.Windows.UI && window.Windows.UI.Input.Inking) {
|
||||
// Initialize Windows Ink recognizer
|
||||
const InkRecognizerContainer = Windows.UI.Input.Inking.InkRecognizerContainer;
|
||||
this.recognizer = new InkRecognizerContainer();
|
||||
|
||||
// Set recognition target to words
|
||||
this.recognizer.setDefaultRecognizer(Windows.UI.Input.Inking.InkRecognitionTarget.allText);
|
||||
|
||||
console.log('✅ Windows Ink API recognizer created');
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Windows Ink API setup failed:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
setupEnhancedTouchRecognition() {
|
||||
// Enhanced touch/pointer recognition with pressure and timing data
|
||||
this.recognizer = {
|
||||
type: 'enhanced_touch',
|
||||
features: {
|
||||
pressure: 'PointerEvent' in window,
|
||||
timing: true,
|
||||
velocity: true
|
||||
}
|
||||
};
|
||||
console.log('✅ Enhanced touch recognition setup complete');
|
||||
}
|
||||
|
||||
initializeCanvas(canvasElement) {
|
||||
this.canvas = canvasElement;
|
||||
this.ctx = canvasElement.getContext('2d');
|
||||
|
||||
// Enhanced drawing setup
|
||||
this.ctx.lineCap = 'round';
|
||||
this.ctx.lineJoin = 'round';
|
||||
this.ctx.lineWidth = 3;
|
||||
this.ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--text-primary').trim();
|
||||
|
||||
this.setupCanvasEvents();
|
||||
}
|
||||
|
||||
setupCanvasEvents() {
|
||||
// Use pointer events for better tablet support
|
||||
if ('PointerEvent' in window) {
|
||||
this.canvas.addEventListener('pointerdown', (e) => this.startStroke(e));
|
||||
this.canvas.addEventListener('pointermove', (e) => this.continueStroke(e));
|
||||
this.canvas.addEventListener('pointerup', (e) => this.endStroke(e));
|
||||
this.canvas.addEventListener('pointercancel', (e) => this.endStroke(e));
|
||||
} else {
|
||||
// Fallback to touch events
|
||||
this.canvas.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
this.startStroke(e.touches[0]);
|
||||
});
|
||||
this.canvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
this.continueStroke(e.touches[0]);
|
||||
});
|
||||
this.canvas.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
this.endStroke(e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
startStroke(event) {
|
||||
this.isDrawing = true;
|
||||
this.currentStroke = [];
|
||||
|
||||
const point = this.getEnhancedPoint(event);
|
||||
this.currentStroke.push(point);
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(point.x, point.y);
|
||||
|
||||
console.log('🖋️ Starting stroke with enhanced data:', point);
|
||||
}
|
||||
|
||||
continueStroke(event) {
|
||||
if (!this.isDrawing) return;
|
||||
|
||||
const point = this.getEnhancedPoint(event);
|
||||
this.currentStroke.push(point);
|
||||
|
||||
this.ctx.lineTo(point.x, point.y);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
|
||||
endStroke(event) {
|
||||
if (!this.isDrawing) return;
|
||||
|
||||
this.isDrawing = false;
|
||||
|
||||
if (this.currentStroke.length > 0) {
|
||||
// Add timing information to the stroke
|
||||
const strokeDuration = this.currentStroke[this.currentStroke.length - 1].timestamp - this.currentStroke[0].timestamp;
|
||||
|
||||
this.strokes.push({
|
||||
points: [...this.currentStroke],
|
||||
duration: strokeDuration,
|
||||
avgPressure: this.calculateAveragePressure(this.currentStroke)
|
||||
});
|
||||
|
||||
console.log(`✅ Stroke completed: ${this.currentStroke.length} points, ${strokeDuration}ms`);
|
||||
}
|
||||
|
||||
this.currentStroke = [];
|
||||
}
|
||||
|
||||
getEnhancedPoint(event) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const scaleX = this.canvas.width / rect.width;
|
||||
const scaleY = this.canvas.height / rect.height;
|
||||
|
||||
const point = {
|
||||
x: (event.clientX - rect.left) * scaleX,
|
||||
y: (event.clientY - rect.top) * scaleY,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Add pressure if available (PointerEvent)
|
||||
if (event.pressure !== undefined) {
|
||||
point.pressure = event.pressure;
|
||||
|
||||
// Detect Apple Pencil based on pressure characteristics
|
||||
if (this.isAppleDevice && event.pressure > 0 && event.pressure < 1) {
|
||||
this.applePencilDetected = true;
|
||||
console.log('🍎 Apple Pencil detected with pressure:', event.pressure);
|
||||
}
|
||||
}
|
||||
|
||||
// Add tilt if available (stylus)
|
||||
if (event.tiltX !== undefined && event.tiltY !== undefined) {
|
||||
point.tiltX = event.tiltX;
|
||||
point.tiltY = event.tiltY;
|
||||
|
||||
// Apple Pencil specific tilt detection
|
||||
if (this.isAppleDevice && (Math.abs(event.tiltX) > 0 || Math.abs(event.tiltY) > 0)) {
|
||||
this.applePencilDetected = true;
|
||||
console.log('🍎 Apple Pencil tilt detected:', event.tiltX, event.tiltY);
|
||||
}
|
||||
}
|
||||
|
||||
// Add force if available (3D Touch / Apple Pencil force)
|
||||
if (event.force !== undefined) {
|
||||
point.force = event.force;
|
||||
|
||||
// Apple Pencil force detection
|
||||
if (this.isAppleDevice && event.force > 0) {
|
||||
this.applePencilDetected = true;
|
||||
console.log('🍎 Apple Pencil force detected:', event.force);
|
||||
}
|
||||
}
|
||||
|
||||
// Apple Pencil specific optimizations
|
||||
if (this.applePencilDetected) {
|
||||
point.isApplePencil = true;
|
||||
|
||||
// Apple Pencil typically has more precise coordinates
|
||||
point.x = Math.round(point.x * 2) / 2; // Sub-pixel precision
|
||||
point.y = Math.round(point.y * 2) / 2;
|
||||
}
|
||||
|
||||
return point;
|
||||
}
|
||||
|
||||
calculateAveragePressure(stroke) {
|
||||
const pressures = stroke.filter(p => p.pressure !== undefined).map(p => p.pressure);
|
||||
return pressures.length > 0 ? pressures.reduce((a, b) => a + b, 0) / pressures.length : 0.5;
|
||||
}
|
||||
|
||||
async recognizeHandwriting() {
|
||||
if (this.strokes.length === 0) {
|
||||
console.log('⚠️ No strokes to recognize');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`🔍 Recognizing ${this.strokes.length} strokes using native APIs...`);
|
||||
|
||||
try {
|
||||
// Try Web Handwriting API first
|
||||
if (this.recognizer && this.recognizer.startDrawing) {
|
||||
return await this.recognizeWithWebAPI();
|
||||
}
|
||||
|
||||
// Try Windows Ink API
|
||||
if (this.recognizer && this.recognizer.recognizeAsync) {
|
||||
return await this.recognizeWithWindowsInk();
|
||||
}
|
||||
|
||||
// Use enhanced pattern recognition
|
||||
return this.recognizeWithEnhancedPatterns();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Native recognition failed:', error);
|
||||
return this.recognizeWithEnhancedPatterns();
|
||||
}
|
||||
}
|
||||
|
||||
async recognizeWithWebAPI() {
|
||||
try {
|
||||
const drawing = this.recognizer.startDrawing();
|
||||
|
||||
// Convert our strokes to the Web API format
|
||||
for (const stroke of this.strokes) {
|
||||
const apiStroke = drawing.addStroke();
|
||||
for (const point of stroke.points) {
|
||||
apiStroke.addPoint({
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
t: point.timestamp
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const predictions = await this.recognizer.getPrediction();
|
||||
|
||||
if (predictions && predictions.length > 0) {
|
||||
const bestPrediction = predictions[0];
|
||||
console.log('✅ Web API recognition:', bestPrediction.text);
|
||||
return bestPrediction.text;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Web API recognition error:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async recognizeWithWindowsInk() {
|
||||
try {
|
||||
// Convert strokes to Windows Ink format
|
||||
const inkStrokes = [];
|
||||
|
||||
for (const stroke of this.strokes) {
|
||||
const inkStrokeBuilder = new Windows.UI.Input.Inking.InkStrokeBuilder();
|
||||
const points = stroke.points.map(p =>
|
||||
Windows.Foundation.Point(p.x, p.y)
|
||||
);
|
||||
const inkStroke = inkStrokeBuilder.createStroke(points);
|
||||
inkStrokes.push(inkStroke);
|
||||
}
|
||||
|
||||
const recognitionResults = await this.recognizer.recognizeAsync(
|
||||
inkStrokes,
|
||||
Windows.UI.Input.Inking.InkRecognitionTarget.allText
|
||||
);
|
||||
|
||||
if (recognitionResults && recognitionResults.length > 0) {
|
||||
const bestResult = recognitionResults[0];
|
||||
console.log('✅ Windows Ink recognition:', bestResult.text);
|
||||
return bestResult.text;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Windows Ink recognition error:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
recognizeWithEnhancedPatterns() {
|
||||
// Enhanced pattern recognition using the additional data we collected
|
||||
console.log('🔍 Using enhanced pattern recognition with native data...');
|
||||
|
||||
const analysis = this.analyzeNativeStrokes();
|
||||
|
||||
// Apple Pencil specific recognition
|
||||
if (this.applePencilDetected) {
|
||||
console.log('🍎 Using Apple Pencil optimized recognition...');
|
||||
const applePencilResult = this.recognizeWithApplePencil(analysis);
|
||||
if (applePencilResult) {
|
||||
return applePencilResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the enhanced data to make better predictions
|
||||
const confidence = this.calculateNativeConfidence(analysis);
|
||||
|
||||
if (confidence > 0.7) {
|
||||
return analysis.predictedText;
|
||||
}
|
||||
|
||||
// Fallback to the original handwriting recognition
|
||||
return null;
|
||||
}
|
||||
|
||||
recognizeWithApplePencil(analysis) {
|
||||
console.log('🍎 Apple Pencil recognition with enhanced accuracy...');
|
||||
|
||||
// Apple Pencil produces very precise strokes, so we can be more strict with pattern matching
|
||||
const { strokeCount, avgPressure, avgDuration } = analysis;
|
||||
|
||||
// Apple Pencil specific word patterns
|
||||
const applePencilPatterns = {
|
||||
'Love': {
|
||||
expectedStrokes: [4, 6],
|
||||
expectedPressure: [0.2, 0.8],
|
||||
expectedDuration: [200, 800],
|
||||
letterShapes: ['vertical_tall', 'circle_round', 'v_angle', 'curve_open']
|
||||
},
|
||||
'love': {
|
||||
expectedStrokes: [4, 6],
|
||||
expectedPressure: [0.2, 0.8],
|
||||
expectedDuration: [200, 800],
|
||||
letterShapes: ['tall_line', 'round_shape', 'angle_lines', 'open_curve']
|
||||
},
|
||||
'Boy': {
|
||||
expectedStrokes: [3, 5],
|
||||
expectedPressure: [0.2, 0.8],
|
||||
expectedDuration: [150, 600],
|
||||
letterShapes: ['loop_tall', 'circle_round', 'descender_y']
|
||||
},
|
||||
'boy': {
|
||||
expectedStrokes: [3, 5],
|
||||
expectedPressure: [0.2, 0.8],
|
||||
expectedDuration: [150, 600],
|
||||
letterShapes: ['tall_loop', 'round_circle', 'y_descender']
|
||||
},
|
||||
'Cat': {
|
||||
expectedStrokes: [3, 5],
|
||||
expectedPressure: [0.2, 0.7],
|
||||
expectedDuration: [120, 500],
|
||||
letterShapes: ['curve_open', 'curve_round', 'cross_horizontal']
|
||||
},
|
||||
'cat': {
|
||||
expectedStrokes: [3, 5],
|
||||
expectedPressure: [0.2, 0.7],
|
||||
expectedDuration: [120, 500],
|
||||
letterShapes: ['open_curve', 'round_curve', 'horizontal_cross']
|
||||
}
|
||||
};
|
||||
|
||||
let bestMatch = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const [word, pattern] of Object.entries(applePencilPatterns)) {
|
||||
let score = 0;
|
||||
|
||||
// Stroke count match (Apple Pencil is precise)
|
||||
if (strokeCount >= pattern.expectedStrokes[0] && strokeCount <= pattern.expectedStrokes[1]) {
|
||||
score += 0.4;
|
||||
}
|
||||
|
||||
// Pressure consistency (Apple Pencil advantage)
|
||||
if (avgPressure >= pattern.expectedPressure[0] && avgPressure <= pattern.expectedPressure[1]) {
|
||||
score += 0.3;
|
||||
}
|
||||
|
||||
// Duration consistency (Apple Pencil users tend to be more deliberate)
|
||||
if (avgDuration >= pattern.expectedDuration[0] && avgDuration <= pattern.expectedDuration[1]) {
|
||||
score += 0.2;
|
||||
}
|
||||
|
||||
// Apple Pencil precision bonus
|
||||
score += 0.1;
|
||||
|
||||
console.log(`🍎 Apple Pencil score for "${word}": ${score.toFixed(3)}`);
|
||||
|
||||
if (score > bestScore && score > 0.6) {
|
||||
bestScore = score;
|
||||
bestMatch = word;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatch) {
|
||||
console.log(`🏆 Apple Pencil recognition: "${bestMatch}" (confidence: ${bestScore.toFixed(3)})`);
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
analyzeNativeStrokes() {
|
||||
// Analyze using the enhanced stroke data
|
||||
let totalPressure = 0;
|
||||
let totalDuration = 0;
|
||||
let pressurePoints = 0;
|
||||
|
||||
for (const stroke of this.strokes) {
|
||||
totalDuration += stroke.duration;
|
||||
if (stroke.avgPressure > 0) {
|
||||
totalPressure += stroke.avgPressure;
|
||||
pressurePoints++;
|
||||
}
|
||||
}
|
||||
|
||||
const avgPressure = pressurePoints > 0 ? totalPressure / pressurePoints : 0.5;
|
||||
const avgStrokeDuration = totalDuration / this.strokes.length;
|
||||
|
||||
// Use pressure and timing to improve recognition
|
||||
return {
|
||||
strokeCount: this.strokes.length,
|
||||
avgPressure: avgPressure,
|
||||
avgDuration: avgStrokeDuration,
|
||||
predictedText: this.makePressureBasedPrediction(avgPressure, avgStrokeDuration),
|
||||
hasEnhancedData: pressurePoints > 0
|
||||
};
|
||||
}
|
||||
|
||||
makePressureBasedPrediction(pressure, duration) {
|
||||
// Use pressure and timing patterns to help predict
|
||||
const strokeCount = this.strokes.length;
|
||||
|
||||
if (strokeCount === 3) {
|
||||
if (pressure > 0.6 && duration < 200) {
|
||||
return 'Boy'; // Quick, heavy strokes
|
||||
} else if (pressure < 0.4 && duration > 300) {
|
||||
return 'Cat'; // Light, deliberate strokes
|
||||
}
|
||||
} else if (strokeCount >= 4) {
|
||||
if (pressure > 0.5 && duration > 250) {
|
||||
return 'Love'; // Moderate pressure, careful strokes
|
||||
} else if (pressure < 0.5 && duration < 200) {
|
||||
return 'Like'; // Light, quick strokes
|
||||
}
|
||||
}
|
||||
|
||||
return null; // No clear pattern
|
||||
}
|
||||
|
||||
calculateNativeConfidence(analysis) {
|
||||
let confidence = 0.5;
|
||||
|
||||
// Bonus for having enhanced data
|
||||
if (analysis.hasEnhancedData) {
|
||||
confidence += 0.2;
|
||||
}
|
||||
|
||||
// Bonus for reasonable pressure patterns
|
||||
if (analysis.avgPressure > 0.2 && analysis.avgPressure < 0.8) {
|
||||
confidence += 0.1;
|
||||
}
|
||||
|
||||
// Bonus for reasonable timing
|
||||
if (analysis.avgDuration > 100 && analysis.avgDuration < 1000) {
|
||||
confidence += 0.1;
|
||||
}
|
||||
|
||||
return Math.min(1.0, confidence);
|
||||
}
|
||||
|
||||
clearCanvas() {
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.strokes = [];
|
||||
console.log('🧹 Canvas cleared, native recognition reset');
|
||||
}
|
||||
|
||||
isNativeSupported() {
|
||||
return this.isSupported;
|
||||
}
|
||||
|
||||
getRecognitionInfo() {
|
||||
return {
|
||||
supported: this.isSupported,
|
||||
type: this.recognizer ? this.recognizer.type || 'native' : 'none',
|
||||
features: this.recognizer ? this.recognizer.features || {} : {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other files
|
||||
window.NativeHandwritingRecognition = NativeHandwritingRecognition;
|
||||
@@ -0,0 +1,995 @@
|
||||
/**
|
||||
* Tablet Menu Management System
|
||||
* Handles collapsible navigation, touch gestures, and optimized menu interactions for tablets
|
||||
*/
|
||||
|
||||
class TabletMenuManager {
|
||||
constructor() {
|
||||
this.isMenuExpanded = false;
|
||||
this.isMinimized = false;
|
||||
this.isWritingActive = false;
|
||||
this.touchStartX = 0;
|
||||
this.touchStartY = 0;
|
||||
this.swipeThreshold = 100;
|
||||
this.isDragging = false;
|
||||
this.dragOffset = { x: 0, y: 0 };
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createMenuToggle();
|
||||
this.createMenuPopup();
|
||||
this.setupEventListeners();
|
||||
this.setupPopupDragging();
|
||||
this.setupHelpBubble();
|
||||
this.optimizeAdminTabs();
|
||||
this.createQuickActions();
|
||||
this.setupWritingAreaDetection();
|
||||
|
||||
console.log('📱 Universal Popup Menu Manager initialized');
|
||||
}
|
||||
|
||||
isTabletDevice() {
|
||||
// Check for tablet characteristics
|
||||
const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
const screenSize = window.innerWidth >= 600 && window.innerWidth <= 1200;
|
||||
const isLandscape = window.innerWidth > window.innerHeight;
|
||||
const aspectRatio = window.innerWidth / window.innerHeight;
|
||||
|
||||
return hasTouch && (screenSize || (isLandscape && aspectRatio < 2));
|
||||
}
|
||||
|
||||
createMenuToggle() {
|
||||
// Remove existing toggle if present
|
||||
const existingToggle = document.querySelector('.menu-toggle');
|
||||
if (existingToggle) existingToggle.remove();
|
||||
|
||||
const toggle = document.createElement('button');
|
||||
toggle.className = 'menu-toggle';
|
||||
toggle.innerHTML = '☰';
|
||||
toggle.setAttribute('aria-label', 'Toggle Navigation Menu');
|
||||
toggle.addEventListener('click', () => this.toggleMenu());
|
||||
|
||||
document.body.appendChild(toggle);
|
||||
this.menuToggle = toggle;
|
||||
}
|
||||
|
||||
createMenuPopup() {
|
||||
// Remove existing popup if present
|
||||
const existingPopup = document.querySelector('.menu-popup');
|
||||
if (existingPopup) existingPopup.remove();
|
||||
|
||||
// Create popup container
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'menu-popup hidden';
|
||||
popup.id = 'menuPopup';
|
||||
|
||||
// Create popup header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'popup-header';
|
||||
header.innerHTML = `
|
||||
<div class="popup-title">
|
||||
<span class="popup-icon">📋</span>
|
||||
<span>Admin Functions</span>
|
||||
</div>
|
||||
<div class="popup-controls">
|
||||
<button class="minimize-btn" title="Minimize">−</button>
|
||||
<button class="close-btn" title="Close">×</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Create popup content (will be populated by existing nav content)
|
||||
const content = document.createElement('div');
|
||||
content.className = 'popup-content';
|
||||
content.id = 'popupContent';
|
||||
|
||||
popup.appendChild(header);
|
||||
popup.appendChild(content);
|
||||
document.body.appendChild(popup);
|
||||
|
||||
this.menuPopup = popup;
|
||||
this.setupPopupControls();
|
||||
}
|
||||
|
||||
setupPopupControls() {
|
||||
const minimizeBtn = this.menuPopup.querySelector('.minimize-btn');
|
||||
const closeBtn = this.menuPopup.querySelector('.close-btn');
|
||||
|
||||
minimizeBtn.addEventListener('click', () => this.minimizePopup());
|
||||
closeBtn.addEventListener('click', () => this.closePopup());
|
||||
}
|
||||
|
||||
setupPopupDragging() {
|
||||
const header = this.menuPopup.querySelector('.popup-header');
|
||||
|
||||
header.addEventListener('mousedown', (e) => this.startDrag(e));
|
||||
header.addEventListener('touchstart', (e) => this.startDrag(e), { passive: false });
|
||||
|
||||
document.addEventListener('mousemove', (e) => this.onDrag(e));
|
||||
document.addEventListener('touchmove', (e) => this.onDrag(e), { passive: false });
|
||||
|
||||
document.addEventListener('mouseup', () => this.endDrag());
|
||||
document.addEventListener('touchend', () => this.endDrag());
|
||||
}
|
||||
|
||||
startDrag(e) {
|
||||
this.isDragging = true;
|
||||
const rect = this.menuPopup.getBoundingClientRect();
|
||||
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
||||
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
|
||||
|
||||
this.dragOffset.x = clientX - rect.left;
|
||||
this.dragOffset.y = clientY - rect.top;
|
||||
|
||||
this.menuPopup.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
onDrag(e) {
|
||||
if (!this.isDragging) return;
|
||||
|
||||
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
||||
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
|
||||
|
||||
const newX = clientX - this.dragOffset.x;
|
||||
const newY = clientY - this.dragOffset.y;
|
||||
|
||||
// Keep popup within viewport bounds
|
||||
const maxX = window.innerWidth - this.menuPopup.offsetWidth;
|
||||
const maxY = window.innerHeight - this.menuPopup.offsetHeight;
|
||||
|
||||
const constrainedX = Math.max(0, Math.min(newX, maxX));
|
||||
const constrainedY = Math.max(0, Math.min(newY, maxY));
|
||||
|
||||
this.menuPopup.style.left = constrainedX + 'px';
|
||||
this.menuPopup.style.top = constrainedY + 'px';
|
||||
this.menuPopup.style.transform = 'none';
|
||||
}
|
||||
|
||||
endDrag() {
|
||||
this.isDragging = false;
|
||||
this.menuPopup.style.cursor = 'default';
|
||||
}
|
||||
|
||||
minimizePopup() {
|
||||
this.isMinimized = !this.isMinimized;
|
||||
const content = this.menuPopup.querySelector('.popup-content');
|
||||
const minimizeBtn = this.menuPopup.querySelector('.minimize-btn');
|
||||
|
||||
if (this.isMinimized) {
|
||||
content.style.display = 'none';
|
||||
this.menuPopup.classList.add('minimized');
|
||||
minimizeBtn.innerHTML = '+';
|
||||
minimizeBtn.title = 'Restore';
|
||||
} else {
|
||||
content.style.display = 'block';
|
||||
this.menuPopup.classList.remove('minimized');
|
||||
minimizeBtn.innerHTML = '−';
|
||||
minimizeBtn.title = 'Minimize';
|
||||
}
|
||||
}
|
||||
|
||||
closePopup() {
|
||||
this.menuPopup.classList.add('hidden');
|
||||
this.menuToggle.classList.remove('active');
|
||||
this.menuToggle.innerHTML = '☰';
|
||||
this.isMenuExpanded = false;
|
||||
this.isMinimized = false;
|
||||
this.showToast('Menu closed');
|
||||
}
|
||||
|
||||
setupHelpBubble() {
|
||||
const helpBubble = document.getElementById('adminHelpBubble');
|
||||
if (!helpBubble) return;
|
||||
|
||||
helpBubble.addEventListener('click', () => this.showHelpPopup());
|
||||
|
||||
// Only show help bubble for admin users
|
||||
this.updateHelpBubbleVisibility();
|
||||
}
|
||||
|
||||
updateHelpBubbleVisibility() {
|
||||
const helpBubble = document.getElementById('adminHelpBubble');
|
||||
const adminPanel = document.getElementById('adminPanel');
|
||||
|
||||
if (helpBubble && adminPanel) {
|
||||
if (!adminPanel.classList.contains('hidden')) {
|
||||
helpBubble.style.display = 'flex';
|
||||
} else {
|
||||
helpBubble.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showHelpPopup() {
|
||||
// Create overlay if it doesn't exist
|
||||
let overlay = document.getElementById('helpOverlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'helpOverlay';
|
||||
overlay.className = 'help-overlay';
|
||||
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'help-popup';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'help-popup-header';
|
||||
header.innerHTML = `
|
||||
<div class="help-popup-title">
|
||||
<span>📱</span>
|
||||
<span>Admin Functions Guide</span>
|
||||
</div>
|
||||
<button class="help-close-btn" onclick="window.tabletMenuManager.closeHelpPopup()">×</button>
|
||||
`;
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'help-popup-content';
|
||||
|
||||
// Copy content from hidden instructions
|
||||
const helpContent = document.getElementById('adminHelpContent');
|
||||
if (helpContent) {
|
||||
content.innerHTML = helpContent.innerHTML;
|
||||
}
|
||||
|
||||
popup.appendChild(header);
|
||||
popup.appendChild(content);
|
||||
overlay.appendChild(popup);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Close on overlay click
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) {
|
||||
this.closeHelpPopup();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape key
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
this.closeHelpPopup();
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
// Show overlay
|
||||
setTimeout(() => {
|
||||
overlay.classList.add('visible');
|
||||
}, 10);
|
||||
|
||||
this.showToast('Help opened');
|
||||
}
|
||||
|
||||
closeHelpPopup() {
|
||||
const overlay = document.getElementById('helpOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
setTimeout(() => {
|
||||
overlay.remove();
|
||||
}, 300);
|
||||
}
|
||||
this.showToast('Help closed');
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.isMenuExpanded) {
|
||||
this.closeMenu();
|
||||
}
|
||||
if (e.ctrlKey && e.key === 'm') {
|
||||
e.preventDefault();
|
||||
this.toggleMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside to close menu
|
||||
document.addEventListener('click', (e) => {
|
||||
const nav = document.getElementById('nav');
|
||||
if (this.isMenuExpanded &&
|
||||
!nav.contains(e.target) &&
|
||||
!this.menuToggle.contains(e.target)) {
|
||||
this.closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Orientation change handling
|
||||
window.addEventListener('orientationchange', () => {
|
||||
setTimeout(() => this.handleOrientationChange(), 100);
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
window.addEventListener('resize', () => {
|
||||
this.handleResize();
|
||||
});
|
||||
}
|
||||
|
||||
setupSwipeGestures() {
|
||||
let startX, startY, startTime;
|
||||
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 1) {
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
startTime = Date.now();
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', (e) => {
|
||||
if (e.changedTouches.length === 1) {
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
const endTime = Date.now();
|
||||
|
||||
const deltaX = endX - startX;
|
||||
const deltaY = endY - startY;
|
||||
const deltaTime = endTime - startTime;
|
||||
|
||||
// Swipe detection
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY) &&
|
||||
Math.abs(deltaX) > this.swipeThreshold &&
|
||||
deltaTime < 500) {
|
||||
|
||||
if (deltaX > 0 && startX < 50) {
|
||||
// Swipe right from left edge - open menu
|
||||
this.openMenu();
|
||||
} else if (deltaX < 0 && this.isMenuExpanded) {
|
||||
// Swipe left when menu open - close menu
|
||||
this.closeMenu();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
optimizeAdminTabs() {
|
||||
const adminTabs = document.getElementById('adminTabs');
|
||||
if (!adminTabs) return;
|
||||
|
||||
// Add scroll indicators
|
||||
this.addScrollIndicators(adminTabs);
|
||||
|
||||
// Smooth scrolling for tab navigation
|
||||
adminTabs.addEventListener('wheel', (e) => {
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
|
||||
e.preventDefault();
|
||||
adminTabs.scrollLeft += e.deltaY;
|
||||
}, { passive: false });
|
||||
|
||||
// Center active tab
|
||||
const activeTab = adminTabs.querySelector('button.active');
|
||||
if (activeTab) {
|
||||
this.scrollToTab(activeTab);
|
||||
}
|
||||
}
|
||||
|
||||
addScrollIndicators(container) {
|
||||
const leftIndicator = document.createElement('div');
|
||||
leftIndicator.className = 'scroll-indicator left';
|
||||
leftIndicator.innerHTML = '‹';
|
||||
|
||||
const rightIndicator = document.createElement('div');
|
||||
rightIndicator.className = 'scroll-indicator right';
|
||||
rightIndicator.innerHTML = '›';
|
||||
|
||||
container.parentNode.insertBefore(leftIndicator, container);
|
||||
container.parentNode.appendChild(rightIndicator);
|
||||
|
||||
// Update indicators on scroll
|
||||
const updateIndicators = () => {
|
||||
leftIndicator.style.opacity = container.scrollLeft > 0 ? '1' : '0.3';
|
||||
rightIndicator.style.opacity =
|
||||
container.scrollLeft < container.scrollWidth - container.clientWidth ? '1' : '0.3';
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', updateIndicators);
|
||||
updateIndicators();
|
||||
|
||||
// Click handlers
|
||||
leftIndicator.addEventListener('click', () => {
|
||||
container.scrollBy({ left: -120, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
rightIndicator.addEventListener('click', () => {
|
||||
container.scrollBy({ left: 120, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
scrollToTab(tab) {
|
||||
const container = tab.parentNode;
|
||||
const tabRect = tab.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
if (tabRect.left < containerRect.left || tabRect.right > containerRect.right) {
|
||||
tab.scrollIntoView({ behavior: 'smooth', inline: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
createQuickActions() {
|
||||
// Remove existing quick actions
|
||||
const existing = document.querySelector('.quick-actions');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const quickActions = document.createElement('div');
|
||||
quickActions.className = 'quick-actions';
|
||||
|
||||
// Back to top button
|
||||
const backToTop = document.createElement('button');
|
||||
backToTop.className = 'quick-action-btn secondary';
|
||||
backToTop.innerHTML = '↑';
|
||||
backToTop.title = 'Back to Top';
|
||||
backToTop.addEventListener('click', () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
quickActions.appendChild(backToTop);
|
||||
|
||||
// Show/hide based on scroll position
|
||||
window.addEventListener('scroll', () => {
|
||||
const scrolled = window.pageYOffset > 200;
|
||||
backToTop.style.opacity = scrolled ? '1' : '0';
|
||||
backToTop.style.pointerEvents = scrolled ? 'auto' : 'none';
|
||||
});
|
||||
|
||||
document.body.appendChild(quickActions);
|
||||
}
|
||||
|
||||
setupWritingAreaDetection() {
|
||||
// Detect when user is actively writing
|
||||
const writingAreas = [
|
||||
'#unifiedWriteContainer canvas',
|
||||
'#handwritingCanvas',
|
||||
'#userInput'
|
||||
];
|
||||
|
||||
writingAreas.forEach(selector => {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
element.addEventListener('focus', () => this.setWritingMode(true));
|
||||
element.addEventListener('blur', () => this.setWritingMode(false));
|
||||
|
||||
if (element.tagName === 'CANVAS') {
|
||||
element.addEventListener('pointerdown', () => this.setWritingMode(true));
|
||||
element.addEventListener('touchstart', () => this.setWritingMode(true));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-close menu when writing starts
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
const isWritingArea = writingAreas.some(selector => {
|
||||
const element = document.querySelector(selector);
|
||||
return element && element.contains(e.target);
|
||||
});
|
||||
|
||||
if (isWritingArea && this.isMenuExpanded) {
|
||||
setTimeout(() => this.closeMenu(), 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setWritingMode(active) {
|
||||
this.isWritingActive = active;
|
||||
document.body.classList.toggle('writing-focused', active);
|
||||
|
||||
if (active && this.isMenuExpanded) {
|
||||
this.closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
toggleMenu() {
|
||||
if (this.isMenuExpanded) {
|
||||
this.closePopup();
|
||||
} else {
|
||||
this.openPopup();
|
||||
}
|
||||
}
|
||||
|
||||
openPopup() {
|
||||
// Move navigation content to popup
|
||||
this.populatePopupContent();
|
||||
|
||||
// Show popup
|
||||
this.menuPopup.classList.remove('hidden');
|
||||
this.menuToggle.classList.add('active');
|
||||
this.menuToggle.innerHTML = '✕';
|
||||
|
||||
// Position popup in center initially
|
||||
if (!this.menuPopup.style.left || !this.menuPopup.style.top) {
|
||||
this.centerPopup();
|
||||
}
|
||||
|
||||
this.isMenuExpanded = true;
|
||||
}
|
||||
|
||||
populatePopupContent() {
|
||||
const content = this.menuPopup.querySelector('.popup-content');
|
||||
const nav = document.getElementById('nav');
|
||||
|
||||
if (nav && content) {
|
||||
// Clone the navigation content
|
||||
content.innerHTML = nav.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
centerPopup() {
|
||||
const rect = this.menuPopup.getBoundingClientRect();
|
||||
const x = (window.innerWidth - rect.width) / 2;
|
||||
const y = (window.innerHeight - rect.height) / 2;
|
||||
|
||||
this.menuPopup.style.left = Math.max(20, x) + 'px';
|
||||
this.menuPopup.style.top = Math.max(20, y) + 'px';
|
||||
this.menuPopup.style.transform = 'none';
|
||||
|
||||
this.showToast('Menu opened');
|
||||
|
||||
// Focus first interactive element
|
||||
setTimeout(() => {
|
||||
const firstButton = this.menuPopup.querySelector('button');
|
||||
if (firstButton) firstButton.focus();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
closeMenu() {
|
||||
// Keep this method for backward compatibility
|
||||
this.closePopup();
|
||||
}
|
||||
|
||||
showToast(message, duration = 2000) {
|
||||
// Remove existing toasts
|
||||
document.querySelectorAll('.toast').forEach(toast => toast.remove());
|
||||
|
||||
// Create toast container if needed
|
||||
let container = document.querySelector('.toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// Create toast
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.textContent = message;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => toast.classList.add('show'), 100);
|
||||
|
||||
// Auto remove
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
handleOrientationChange() {
|
||||
const nav = document.getElementById('nav');
|
||||
if (!nav) return;
|
||||
|
||||
const isLandscape = window.innerWidth > window.innerHeight;
|
||||
|
||||
if (isLandscape && window.innerWidth >= 900) {
|
||||
nav.classList.add('landscape');
|
||||
document.body.classList.toggle('landscape-nav-expanded', this.isMenuExpanded);
|
||||
} else {
|
||||
nav.classList.remove('landscape');
|
||||
document.body.classList.remove('landscape-nav-expanded');
|
||||
}
|
||||
|
||||
// Reoptimize admin tabs
|
||||
setTimeout(() => this.optimizeAdminTabs(), 100);
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
if (!this.isTabletDevice() && this.isMenuExpanded) {
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
// Re-evaluate tablet status
|
||||
if (this.isTabletDevice() && !this.menuToggle) {
|
||||
this.createMenuToggle();
|
||||
} else if (!this.isTabletDevice() && this.menuToggle) {
|
||||
this.menuToggle.remove();
|
||||
this.menuToggle = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced navigation functions
|
||||
updateUserInfo() {
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
const userBadge = document.getElementById('userBadge');
|
||||
|
||||
if (window.currentUser) {
|
||||
const { username, role } = window.currentUser;
|
||||
|
||||
// Don't override if userInfo already has content from main UI
|
||||
if (userInfo && !userInfo.textContent.includes('(')) {
|
||||
userInfo.textContent = `Welcome, ${username}`;
|
||||
}
|
||||
|
||||
if (userBadge) {
|
||||
userBadge.textContent = role === 'admin' ? '👨💼 Admin' : '🎓 Student';
|
||||
userBadge.style.background = role === 'admin' ? 'var(--error)' : 'var(--primary)';
|
||||
}
|
||||
|
||||
this.showRoleSpecificMenu(role);
|
||||
}
|
||||
}
|
||||
|
||||
showRoleSpecificMenu(role) {
|
||||
const studentNav = document.getElementById('studentNavItems');
|
||||
const adminNav = document.getElementById('adminNavItems');
|
||||
|
||||
if (role === 'admin') {
|
||||
if (studentNav) studentNav.classList.add('hidden');
|
||||
if (adminNav) adminNav.classList.remove('hidden');
|
||||
} else {
|
||||
if (adminNav) adminNav.classList.add('hidden');
|
||||
if (studentNav) studentNav.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Public API methods
|
||||
isMenuOpen() {
|
||||
return this.isMenuExpanded;
|
||||
}
|
||||
|
||||
forceCloseMenu() {
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
updateNavigation() {
|
||||
this.updateUserInfo();
|
||||
}
|
||||
|
||||
setTabActive(tabId) {
|
||||
const adminTabs = document.getElementById('adminTabs');
|
||||
if (!adminTabs) return;
|
||||
|
||||
const tab = adminTabs.querySelector(`[data-tab="${tabId}"]`);
|
||||
if (tab) {
|
||||
// Remove active class from all tabs
|
||||
adminTabs.querySelectorAll('button').forEach(btn => btn.classList.remove('active'));
|
||||
|
||||
// Add active class to selected tab
|
||||
tab.classList.add('active');
|
||||
|
||||
// Scroll to active tab
|
||||
this.scrollToTab(tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize tablet menu manager when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Wait for other systems to initialize
|
||||
setTimeout(() => {
|
||||
try {
|
||||
window.tabletMenuManager = new TabletMenuManager();
|
||||
} catch (error) {
|
||||
console.warn('Tablet Menu Manager failed to initialize:', error);
|
||||
}
|
||||
}, 1000); // Increased delay to ensure other systems are ready
|
||||
});
|
||||
|
||||
// Handle admin tab clicks to update active state
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.matches('#adminTabs button[data-tab]')) {
|
||||
if (window.tabletMenuManager) {
|
||||
window.tabletMenuManager.setTabActive(e.target.dataset.tab);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions for enhanced navigation
|
||||
function closeMenuIfTablet() {
|
||||
if (window.tabletMenuManager && window.tabletMenuManager.isMenuOpen()) {
|
||||
window.tabletMenuManager.forceCloseMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function showAdminTab(tabId) {
|
||||
// Show admin panel first
|
||||
if (window.showAdmin) {
|
||||
window.showAdmin();
|
||||
}
|
||||
|
||||
// Then activate the specific tab
|
||||
setTimeout(() => {
|
||||
// Hide the dashboard overview
|
||||
const adminOverview = document.querySelector('.admin-overview');
|
||||
const menuInstructions = document.querySelector('.menu-instructions');
|
||||
if (adminOverview) adminOverview.style.display = 'none';
|
||||
if (menuInstructions) menuInstructions.style.display = 'none';
|
||||
|
||||
// Don't show the old admin tabs interface - keep it hidden
|
||||
const adminTabsContainer = document.querySelector('#adminTabs').parentElement;
|
||||
if (adminTabsContainer) {
|
||||
adminTabsContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Show the specific admin tab content directly
|
||||
document.querySelectorAll('.adminTab').forEach(tab => {
|
||||
tab.classList.add('hidden');
|
||||
});
|
||||
|
||||
const targetTab = document.getElementById(tabId);
|
||||
if (targetTab) {
|
||||
targetTab.classList.remove('hidden');
|
||||
targetTab.style.display = 'block';
|
||||
}
|
||||
|
||||
// Add a back to dashboard button
|
||||
addBackToDashboardButton();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function showChallenges() {
|
||||
// Navigate to student dashboard first
|
||||
if (window.showStudent) {
|
||||
window.showStudent();
|
||||
}
|
||||
|
||||
// Scroll to challenges section
|
||||
setTimeout(() => {
|
||||
const challengesContainer = document.getElementById('challengesContainer');
|
||||
if (challengesContainer) {
|
||||
challengesContainer.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function showLeaderboards() {
|
||||
// Navigate to student dashboard first
|
||||
if (window.showStudent) {
|
||||
window.showStudent();
|
||||
}
|
||||
|
||||
// Scroll to leaderboards section
|
||||
setTimeout(() => {
|
||||
const leaderboardsContainer = document.getElementById('leaderboardsContainer');
|
||||
if (leaderboardsContainer) {
|
||||
leaderboardsContainer.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function showBadges() {
|
||||
// Navigate to student dashboard first
|
||||
if (window.showStudent) {
|
||||
window.showStudent();
|
||||
}
|
||||
|
||||
// Scroll to user stats which includes badges
|
||||
setTimeout(() => {
|
||||
const userStatsContainer = document.getElementById('userStatsContainer');
|
||||
if (userStatsContainer) {
|
||||
userStatsContainer.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function showThemeSelector() {
|
||||
// Navigate to student dashboard first
|
||||
if (window.showStudent) {
|
||||
window.showStudent();
|
||||
}
|
||||
|
||||
// Focus on theme selector
|
||||
setTimeout(() => {
|
||||
const themeSelect = document.getElementById('studentThemeSelect');
|
||||
if (themeSelect) {
|
||||
themeSelect.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
themeSelect.focus();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function showInputSettings() {
|
||||
// Show unified write mode if available
|
||||
if (window.unifiedWriteMode) {
|
||||
// Navigate to student dashboard first
|
||||
if (window.showStudent) {
|
||||
window.showStudent();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
// Start a game to show the input system
|
||||
if (window.startGame) {
|
||||
window.startGame();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// Update navigation when user changes
|
||||
function updateNavigationForUser() {
|
||||
try {
|
||||
if (window.tabletMenuManager) {
|
||||
window.tabletMenuManager.updateNavigation();
|
||||
} else {
|
||||
// Fallback: update navigation elements directly
|
||||
updateNavigationFallback();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Navigation update failed:', error);
|
||||
updateNavigationFallback();
|
||||
}
|
||||
}
|
||||
|
||||
function updateNavigationFallback() {
|
||||
if (window.currentUser) {
|
||||
const { role } = window.currentUser;
|
||||
const studentNav = document.getElementById('studentNavItems');
|
||||
const adminNav = document.getElementById('adminNavItems');
|
||||
|
||||
if (role === 'admin') {
|
||||
if (studentNav) studentNav.classList.add('hidden');
|
||||
if (adminNav) adminNav.classList.remove('hidden');
|
||||
} else {
|
||||
if (adminNav) adminNav.classList.add('hidden');
|
||||
if (studentNav) studentNav.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addBackToDashboardButton() {
|
||||
// Remove existing back button
|
||||
const existingButton = document.querySelector('.back-to-dashboard');
|
||||
if (existingButton) existingButton.remove();
|
||||
|
||||
// Add back button to admin panel
|
||||
const adminPanel = document.getElementById('adminPanel');
|
||||
if (adminPanel) {
|
||||
const backButton = document.createElement('button');
|
||||
backButton.className = 'back-to-dashboard btn-secondary';
|
||||
backButton.innerHTML = '← Back to Dashboard';
|
||||
backButton.style.cssText = 'margin-bottom: 1rem; display: inline-block;';
|
||||
backButton.onclick = showAdminDashboard;
|
||||
|
||||
// Insert at the beginning of admin panel
|
||||
const firstChild = adminPanel.firstElementChild;
|
||||
if (firstChild) {
|
||||
adminPanel.insertBefore(backButton, firstChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showAdminDashboard() {
|
||||
// Show the dashboard overview
|
||||
const adminOverview = document.querySelector('.admin-overview');
|
||||
const menuInstructions = document.querySelector('.menu-instructions');
|
||||
if (adminOverview) adminOverview.style.display = 'block';
|
||||
if (menuInstructions) menuInstructions.style.display = 'block';
|
||||
|
||||
// Hide the admin tabs container
|
||||
const adminTabs = document.getElementById('adminTabs');
|
||||
if (adminTabs && adminTabs.parentElement) {
|
||||
adminTabs.parentElement.style.display = 'none';
|
||||
}
|
||||
|
||||
// Hide all admin tab content
|
||||
document.querySelectorAll('.adminTab').forEach(tab => {
|
||||
tab.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Remove back button
|
||||
const backButton = document.querySelector('.back-to-dashboard');
|
||||
if (backButton) backButton.remove();
|
||||
|
||||
// Load dashboard stats
|
||||
loadAdminDashboardStats();
|
||||
}
|
||||
|
||||
function loadAdminDashboardStats() {
|
||||
// Update stats with real data from server
|
||||
try {
|
||||
// Fetch and display total users
|
||||
fetchDashboardStat('/getUsers', 'totalUsers', (data) => {
|
||||
return Array.isArray(data) ? data.length : Object.keys(data).length;
|
||||
});
|
||||
|
||||
// Fetch and display total word lists
|
||||
fetchDashboardStat('/getWordlistsRaw', 'totalWordLists', (data) => {
|
||||
return Object.keys(data).length;
|
||||
});
|
||||
|
||||
// Fetch and display total badges awarded
|
||||
fetchDashboardStat('/getBadges', 'totalBadges', (data) => {
|
||||
let totalAwarded = 0;
|
||||
if (data && typeof data === 'object') {
|
||||
Object.values(data).forEach(userBadges => {
|
||||
if (Array.isArray(userBadges)) {
|
||||
totalAwarded += userBadges.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
return totalAwarded;
|
||||
});
|
||||
|
||||
// Calculate active sessions (users who have results in last 24 hours)
|
||||
fetchDashboardStat('/getResults', 'totalSessions', (data) => {
|
||||
const oneDayAgo = Date.now() - (24 * 60 * 60 * 1000);
|
||||
let activeSessions = 0;
|
||||
if (data && Array.isArray(data)) {
|
||||
const recentUsers = new Set();
|
||||
data.forEach(result => {
|
||||
if (result.timestamp && new Date(result.timestamp).getTime() > oneDayAgo) {
|
||||
recentUsers.add(result.username);
|
||||
}
|
||||
});
|
||||
activeSessions = recentUsers.size;
|
||||
}
|
||||
return activeSessions;
|
||||
});
|
||||
|
||||
// Update last updated timestamp
|
||||
updateLastUpdatedTime();
|
||||
|
||||
// Set up auto-refresh every 30 seconds
|
||||
if (!window.dashboardStatsInterval) {
|
||||
window.dashboardStatsInterval = setInterval(() => {
|
||||
loadAdminDashboardStats();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Could not load dashboard stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the last updated timestamp
|
||||
function updateLastUpdatedTime() {
|
||||
const element = document.getElementById('lastUpdated');
|
||||
if (element) {
|
||||
const now = new Date();
|
||||
element.textContent = `Last updated: ${now.toLocaleTimeString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to fetch and update dashboard statistics
|
||||
function fetchDashboardStat(endpoint, elementId, processor) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
// Show loading state
|
||||
const originalText = element.textContent;
|
||||
element.style.opacity = '0.6';
|
||||
element.style.transform = 'scale(0.95)';
|
||||
|
||||
fetch(endpoint)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const value = processor(data);
|
||||
setTimeout(() => {
|
||||
element.textContent = value;
|
||||
element.style.opacity = '1';
|
||||
element.style.transform = 'scale(1.05)';
|
||||
|
||||
// Add success pulse animation
|
||||
setTimeout(() => {
|
||||
element.style.transform = 'scale(1)';
|
||||
element.style.color = 'var(--success)';
|
||||
setTimeout(() => {
|
||||
element.style.color = 'var(--text-primary)';
|
||||
}, 500);
|
||||
}, 150);
|
||||
}, 200);
|
||||
})
|
||||
.catch(error => {
|
||||
console.warn(`Failed to fetch ${endpoint}:`, error);
|
||||
element.textContent = originalText === '-' ? '!' : originalText;
|
||||
element.style.opacity = '0.7';
|
||||
element.style.transform = 'scale(1)';
|
||||
element.style.color = 'var(--error)';
|
||||
setTimeout(() => {
|
||||
element.style.color = 'var(--text-primary)';
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Export for use in other scripts
|
||||
window.TabletMenuManager = TabletMenuManager;
|
||||
@@ -0,0 +1,388 @@
|
||||
// Tablet optimization utilities for Spelling Practice App
|
||||
|
||||
class TabletOptimizations {
|
||||
constructor() {
|
||||
this.isTablet = this.detectTablet();
|
||||
this.touchStartTime = 0;
|
||||
this.touchStartPos = { x: 0, y: 0 };
|
||||
|
||||
if (this.isTablet) {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
detectTablet() {
|
||||
// Basic tablet detection
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
const isTabletSize = window.innerWidth >= 768 && window.innerWidth <= 1024;
|
||||
const isTabletUA = /tablet|ipad|playbook|silk/i.test(userAgent);
|
||||
|
||||
return isTouch && (isTabletSize || isTabletUA);
|
||||
}
|
||||
|
||||
init() {
|
||||
console.log('📱 Initializing tablet optimizations');
|
||||
|
||||
this.addTabletClasses();
|
||||
this.optimizeButtons();
|
||||
this.improveScrolling();
|
||||
this.addTouchFeedback();
|
||||
this.optimizeKeyboard();
|
||||
this.handleOrientationChange();
|
||||
|
||||
// Initialize after DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.setupTabletUI();
|
||||
});
|
||||
} else {
|
||||
this.setupTabletUI();
|
||||
}
|
||||
}
|
||||
|
||||
addTabletClasses() {
|
||||
document.documentElement.classList.add('tablet-device');
|
||||
document.body.classList.add('tablet-optimized');
|
||||
}
|
||||
|
||||
optimizeButtons() {
|
||||
// Add enhanced button interactions
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
if (e.target.tagName === 'BUTTON' || e.target.closest('button')) {
|
||||
const button = e.target.tagName === 'BUTTON' ? e.target : e.target.closest('button');
|
||||
button.classList.add('touch-active');
|
||||
|
||||
this.touchStartTime = Date.now();
|
||||
this.touchStartPos = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', (e) => {
|
||||
// Remove touch feedback after a short delay
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.touch-active').forEach(btn => {
|
||||
btn.classList.remove('touch-active');
|
||||
});
|
||||
}, 150);
|
||||
}, { passive: true });
|
||||
|
||||
// Handle long press for additional options
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
if (e.target.classList.contains('activity-option')) {
|
||||
this.longPressTimer = setTimeout(() => {
|
||||
this.showActivityOptions(e.target);
|
||||
}, 800);
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', () => {
|
||||
if (this.longPressTimer) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
improveScrolling() {
|
||||
// Smooth scrolling for tablets
|
||||
document.documentElement.style.scrollBehavior = 'smooth';
|
||||
|
||||
// Prevent bounce scrolling on iOS
|
||||
document.body.style.overscrollBehavior = 'none';
|
||||
|
||||
// Add momentum scrolling
|
||||
const scrollableElements = document.querySelectorAll('.scrollable, .card, .adminTab');
|
||||
scrollableElements.forEach(el => {
|
||||
el.style.webkitOverflowScrolling = 'touch';
|
||||
});
|
||||
}
|
||||
|
||||
addTouchFeedback() {
|
||||
// Add haptic feedback (if available)
|
||||
window.vibrate = window.vibrate || window.webkitVibrate || window.mozVibrate;
|
||||
|
||||
// Visual touch feedback
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.tablet-device .touch-active {
|
||||
transform: scale(0.98) !important;
|
||||
transition: transform 0.1s ease-out !important;
|
||||
background-color: rgba(59, 130, 246, 0.1) !important;
|
||||
}
|
||||
|
||||
.tablet-device button:active,
|
||||
.tablet-device .activity-option:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
|
||||
.tablet-device input:focus,
|
||||
.tablet-device textarea:focus,
|
||||
.tablet-device canvas:focus {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2) !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
optimizeKeyboard() {
|
||||
// Prevent zoom on input focus for iOS
|
||||
const inputs = document.querySelectorAll('input, textarea, select');
|
||||
inputs.forEach(input => {
|
||||
if (input.style.fontSize === '' || parseFloat(input.style.fontSize) < 16) {
|
||||
input.style.fontSize = '16px';
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-scroll to input when keyboard appears
|
||||
inputs.forEach(input => {
|
||||
input.addEventListener('focus', () => {
|
||||
setTimeout(() => {
|
||||
input.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}, 300); // Wait for keyboard animation
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleOrientationChange() {
|
||||
let orientationTimeout;
|
||||
|
||||
window.addEventListener('orientationchange', () => {
|
||||
// Clear existing timeout
|
||||
if (orientationTimeout) {
|
||||
clearTimeout(orientationTimeout);
|
||||
}
|
||||
|
||||
// Wait for orientation change to complete
|
||||
orientationTimeout = setTimeout(() => {
|
||||
this.adjustForOrientation();
|
||||
|
||||
// Trigger canvas resize if handwriting is active
|
||||
if (window.handwritingRecognition && window.handwritingRecognition.resizeCanvas) {
|
||||
window.handwritingRecognition.resizeCanvas();
|
||||
}
|
||||
|
||||
// Re-focus current input if any
|
||||
const activeElement = document.activeElement;
|
||||
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
|
||||
activeElement.blur();
|
||||
setTimeout(() => activeElement.focus(), 100);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
adjustForOrientation() {
|
||||
const isLandscape = window.innerWidth > window.innerHeight;
|
||||
|
||||
if (isLandscape) {
|
||||
document.body.classList.add('tablet-landscape');
|
||||
document.body.classList.remove('tablet-portrait');
|
||||
} else {
|
||||
document.body.classList.add('tablet-portrait');
|
||||
document.body.classList.remove('tablet-landscape');
|
||||
}
|
||||
|
||||
// Adjust game section for landscape
|
||||
const gameSection = document.getElementById('gameSection');
|
||||
const wordBox = document.getElementById('wordBox');
|
||||
|
||||
if (gameSection && wordBox) {
|
||||
if (isLandscape) {
|
||||
gameSection.style.minHeight = '70vh';
|
||||
gameSection.style.display = 'flex';
|
||||
gameSection.style.flexDirection = 'column';
|
||||
gameSection.style.justifyContent = 'center';
|
||||
} else {
|
||||
gameSection.style.minHeight = 'auto';
|
||||
gameSection.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupTabletUI() {
|
||||
// Enhance activity options with better touch targets
|
||||
const activityOptions = document.querySelectorAll('.activity-option');
|
||||
activityOptions.forEach((option, index) => {
|
||||
// Add touch ripple effect
|
||||
option.addEventListener('touchstart', (e) => {
|
||||
this.createRipple(e, option);
|
||||
});
|
||||
|
||||
// Add accessibility improvements
|
||||
option.setAttribute('tabindex', '0');
|
||||
option.setAttribute('role', 'button');
|
||||
|
||||
// Keyboard navigation
|
||||
option.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
option.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Improve admin tabs for tablet
|
||||
const adminTabs = document.getElementById('adminTabs');
|
||||
if (adminTabs) {
|
||||
adminTabs.classList.add('tablet-optimized-tabs');
|
||||
}
|
||||
|
||||
// Add swipe gestures for navigation (optional)
|
||||
this.addSwipeGestures();
|
||||
}
|
||||
|
||||
createRipple(event, element) {
|
||||
const ripple = document.createElement('span');
|
||||
const rect = element.getBoundingClientRect();
|
||||
const size = Math.max(rect.width, rect.height);
|
||||
const x = event.touches[0].clientX - rect.left - size / 2;
|
||||
const y = event.touches[0].clientY - rect.top - size / 2;
|
||||
|
||||
ripple.style.cssText = `
|
||||
position: absolute;
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
transform: scale(0);
|
||||
animation: ripple-animation 0.6s ease-out;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
// Add ripple animation if not exists
|
||||
if (!document.getElementById('ripple-style')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'ripple-style';
|
||||
style.textContent = `
|
||||
@keyframes ripple-animation {
|
||||
to {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
element.style.position = 'relative';
|
||||
element.style.overflow = 'hidden';
|
||||
element.appendChild(ripple);
|
||||
|
||||
setTimeout(() => {
|
||||
ripple.remove();
|
||||
}, 600);
|
||||
}
|
||||
|
||||
addSwipeGestures() {
|
||||
let startX, startY, startTime;
|
||||
|
||||
document.addEventListener('touchstart', (e) => {
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
startTime = Date.now();
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener('touchend', (e) => {
|
||||
if (!startX || !startY) return;
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
const endTime = Date.now();
|
||||
|
||||
const deltaX = endX - startX;
|
||||
const deltaY = endY - startY;
|
||||
const deltaTime = endTime - startTime;
|
||||
|
||||
// Check if it's a swipe (fast, directional movement)
|
||||
if (deltaTime < 300 && Math.abs(deltaX) > 100 && Math.abs(deltaY) < 80) {
|
||||
if (deltaX > 0) {
|
||||
// Swipe right
|
||||
this.handleSwipeRight();
|
||||
} else {
|
||||
// Swipe left
|
||||
this.handleSwipeLeft();
|
||||
}
|
||||
}
|
||||
|
||||
startX = startY = null;
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
handleSwipeLeft() {
|
||||
// Navigate forward or show next content
|
||||
console.log('👈 Swipe left detected');
|
||||
}
|
||||
|
||||
handleSwipeRight() {
|
||||
// Navigate back or show previous content
|
||||
console.log('👉 Swipe right detected');
|
||||
|
||||
// Go back to dashboard if in game/typing mode
|
||||
const gameSection = document.getElementById('gameSection');
|
||||
const typingSection = document.getElementById('typingSection');
|
||||
const bibleSection = document.getElementById('bibleSection');
|
||||
|
||||
if (!gameSection.classList.contains('hidden') && typeof backToStudentDashboard === 'function') {
|
||||
backToStudentDashboard();
|
||||
} else if (!typingSection.classList.contains('hidden') && typeof returnToStudentPanel === 'function') {
|
||||
returnToStudentPanel();
|
||||
} else if (!bibleSection.classList.contains('hidden') && typeof returnToStudentPanel === 'function') {
|
||||
returnToStudentPanel();
|
||||
}
|
||||
}
|
||||
|
||||
showActivityOptions(element) {
|
||||
// Show additional options on long press
|
||||
if (window.vibrate) {
|
||||
window.vibrate(50); // Haptic feedback
|
||||
}
|
||||
|
||||
// Add visual feedback
|
||||
element.style.transform = 'scale(1.05)';
|
||||
element.style.boxShadow = '0 8px 25px rgba(0,0,0,0.2)';
|
||||
|
||||
setTimeout(() => {
|
||||
element.style.transform = '';
|
||||
element.style.boxShadow = '';
|
||||
}, 200);
|
||||
|
||||
console.log('📋 Long press detected on activity option');
|
||||
}
|
||||
|
||||
// Public methods
|
||||
vibrate(pattern = 50) {
|
||||
if (window.vibrate && this.isTablet) {
|
||||
window.vibrate(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
isTabletDevice() {
|
||||
return this.isTablet;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize tablet optimizations
|
||||
let tabletOptimizations = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
tabletOptimizations = new TabletOptimizations();
|
||||
|
||||
// Make available globally
|
||||
window.tabletOptimizations = tabletOptimizations;
|
||||
|
||||
console.log(`📱 Tablet optimization ${tabletOptimizations.isTabletDevice() ? 'enabled' : 'not needed'}`);
|
||||
});
|
||||
|
||||
// Export for module usage
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = TabletOptimizations;
|
||||
}
|
||||
+64
-15
@@ -1,37 +1,60 @@
|
||||
// 🎭 Role-Based UI Setup
|
||||
function setupSession(user) {
|
||||
document.getElementById('loginPanel').classList.add('hidden');
|
||||
document.getElementById('adminPanel').classList.add('hidden');
|
||||
document.getElementById('studentPanel').classList.add('hidden');
|
||||
document.getElementById('nav').classList.remove('hidden');
|
||||
// Safe element hiding/showing with null checks
|
||||
const loginPanel = document.getElementById('loginPanel');
|
||||
const adminPanel = document.getElementById('adminPanel');
|
||||
const studentPanel = document.getElementById('studentPanel');
|
||||
const nav = document.getElementById('nav');
|
||||
|
||||
document.getElementById('userInfo').textContent = `${user.username} (${user.role})`;
|
||||
if (loginPanel) loginPanel.classList.add('hidden');
|
||||
if (adminPanel) adminPanel.classList.add('hidden');
|
||||
if (studentPanel) studentPanel.classList.add('hidden');
|
||||
if (nav) nav.classList.remove('hidden');
|
||||
|
||||
// Store current user globally for tablet menu manager
|
||||
window.currentUser = user;
|
||||
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
if (userInfo) {
|
||||
userInfo.textContent = `${user.username} (${user.role})`;
|
||||
}
|
||||
|
||||
// 🎨 Banner Setup
|
||||
const roleBanner = document.getElementById('roleBanner');
|
||||
roleBanner.classList.remove('hidden', 'admin', 'student');
|
||||
roleBanner.classList.add(user.role);
|
||||
roleBanner.textContent = user.role === "admin"
|
||||
? "👨🏫 Admin Access Enabled"
|
||||
: "🎓 Student Mode Active";
|
||||
if (roleBanner) {
|
||||
roleBanner.classList.remove('hidden', 'admin', 'student');
|
||||
roleBanner.classList.add(user.role);
|
||||
roleBanner.textContent = user.role === "admin"
|
||||
? "👨🏫 Admin Access Enabled"
|
||||
: "🎓 Student Mode Active";
|
||||
}
|
||||
|
||||
// 🎚 Role-Based Navigation
|
||||
const studentBtn = document.getElementById('studentBtn');
|
||||
const adminBtn = document.getElementById('adminBtn');
|
||||
|
||||
if (user.role === "admin") {
|
||||
studentBtn.classList.remove('hidden');
|
||||
adminBtn.classList.remove('hidden');
|
||||
if (studentBtn) studentBtn.classList.remove('hidden');
|
||||
if (adminBtn) adminBtn.classList.remove('hidden');
|
||||
showAdmin();
|
||||
} else if (user.role === "student") {
|
||||
studentBtn.classList.add('hidden');
|
||||
adminBtn.classList.add('hidden');
|
||||
if (studentBtn) studentBtn.classList.add('hidden');
|
||||
if (adminBtn) adminBtn.classList.add('hidden');
|
||||
showStudent();
|
||||
} else {
|
||||
alert("Unknown role. Logging out for safety.");
|
||||
logoutUser();
|
||||
}
|
||||
|
||||
// Update enhanced navigation menu
|
||||
try {
|
||||
if (typeof updateNavigationForUser === 'function') {
|
||||
updateNavigationForUser();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Enhanced navigation update failed:', error);
|
||||
}
|
||||
|
||||
showToast(`Welcome, ${user.role}!`);
|
||||
}
|
||||
|
||||
@@ -77,6 +100,20 @@ function showStudent() {
|
||||
if (studentThemeSelect) {
|
||||
studentThemeSelect.value = savedTheme;
|
||||
}
|
||||
|
||||
// Initialize dashboard containers with default content
|
||||
if (typeof initializeStudentContainers === 'function') {
|
||||
setTimeout(() => {
|
||||
initializeStudentContainers();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Update help bubble visibility
|
||||
if (window.tabletMenuManager && typeof window.tabletMenuManager.updateHelpBubbleVisibility === 'function') {
|
||||
setTimeout(() => {
|
||||
window.tabletMenuManager.updateHelpBubbleVisibility();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +128,19 @@ function showAdmin() {
|
||||
if (adminPanel) {
|
||||
adminPanel.classList.remove("hidden");
|
||||
|
||||
// Load user data for dropdowns
|
||||
// Show dashboard view by default
|
||||
if (typeof showAdminDashboard === 'function') {
|
||||
showAdminDashboard();
|
||||
}
|
||||
|
||||
// Update help bubble visibility for admin
|
||||
if (window.tabletMenuManager && typeof window.tabletMenuManager.updateHelpBubbleVisibility === 'function') {
|
||||
setTimeout(() => {
|
||||
window.tabletMenuManager.updateHelpBubbleVisibility();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Load user data for dropdowns (for when tabs are accessed)
|
||||
if (typeof window.loadUsers === 'function') {
|
||||
window.loadUsers().then(() => {
|
||||
console.log("✅ Users loaded from server");
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* Unified Write Mode - Large Canvas with Handwriting + Keyboard Support
|
||||
* Combines handwriting recognition and keyboard input in one interface
|
||||
*/
|
||||
|
||||
class UnifiedWriteMode {
|
||||
constructor() {
|
||||
this.currentMode = 'keyboard'; // 'keyboard' or 'handwriting'
|
||||
this.canvas = null;
|
||||
this.ctx = null;
|
||||
this.textArea = null;
|
||||
this.container = null;
|
||||
this.handwritingRecognizer = null;
|
||||
|
||||
// Handwriting state
|
||||
this.isDrawing = false;
|
||||
this.strokes = [];
|
||||
this.currentStroke = [];
|
||||
|
||||
// Recognition state
|
||||
this.recognizedText = '';
|
||||
this.isRecognizing = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
console.log('🔄 Initializing Unified Write Mode...');
|
||||
this.createInterface();
|
||||
this.setupEventListeners();
|
||||
this.initializeHandwritingRecognition();
|
||||
}
|
||||
|
||||
createInterface() {
|
||||
// Find or create the main container
|
||||
this.container = document.getElementById('unifiedWriteContainer');
|
||||
|
||||
if (!this.container) {
|
||||
this.container = document.createElement('div');
|
||||
this.container.id = 'unifiedWriteContainer';
|
||||
this.container.className = 'unified-write-mode';
|
||||
|
||||
// Insert into the game container or create new section
|
||||
const gameContainer = document.getElementById('game-container');
|
||||
if (gameContainer) {
|
||||
gameContainer.appendChild(this.container);
|
||||
} else {
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
}
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="input-mode-selector">
|
||||
<button class="mode-button active" data-mode="keyboard">
|
||||
<span class="icon">⌨️</span>
|
||||
<span>Keyboard</span>
|
||||
</button>
|
||||
<button class="mode-button" data-mode="handwriting">
|
||||
<span class="icon">✏️</span>
|
||||
<span>Handwriting</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="large-writing-canvas keyboard-mode">
|
||||
<div class="input-method-indicator">Keyboard Mode</div>
|
||||
|
||||
<textarea
|
||||
class="text-input-area"
|
||||
placeholder="Type your answer here or switch to handwriting mode..."
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
|
||||
<canvas class="canvas-overlay"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="canvas-controls">
|
||||
<button class="btn btn-recognize" id="recognizeBtn" style="display: none;">
|
||||
<span>🔍</span>
|
||||
<span>Recognize</span>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-clear" id="clearBtn">
|
||||
<span>🧹</span>
|
||||
<span>Clear</span>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-submit" id="submitBtn">
|
||||
<span>✅</span>
|
||||
<span>Submit Answer</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="recognition-feedback" id="recognitionFeedback"></div>
|
||||
`;
|
||||
|
||||
// Get references to key elements
|
||||
this.textArea = this.container.querySelector('.text-input-area');
|
||||
this.canvas = this.container.querySelector('.canvas-overlay');
|
||||
this.canvasContainer = this.container.querySelector('.large-writing-canvas');
|
||||
this.indicator = this.container.querySelector('.input-method-indicator');
|
||||
|
||||
// Setup canvas
|
||||
this.setupCanvas();
|
||||
|
||||
console.log('✅ Interface created successfully');
|
||||
}
|
||||
|
||||
setupCanvas() {
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.resizeCanvas();
|
||||
|
||||
// Configure canvas for drawing
|
||||
this.ctx.lineCap = 'round';
|
||||
this.ctx.lineJoin = 'round';
|
||||
this.ctx.lineWidth = 3;
|
||||
this.ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim();
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => this.resizeCanvas());
|
||||
}
|
||||
|
||||
resizeCanvas() {
|
||||
const rect = this.canvasContainer.getBoundingClientRect();
|
||||
this.canvas.width = rect.width;
|
||||
this.canvas.height = rect.height;
|
||||
|
||||
// Reconfigure context after resize
|
||||
this.ctx.lineCap = 'round';
|
||||
this.ctx.lineJoin = 'round';
|
||||
this.ctx.lineWidth = 3;
|
||||
this.ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Mode switching
|
||||
const modeButtons = this.container.querySelectorAll('.mode-button');
|
||||
modeButtons.forEach(button => {
|
||||
button.addEventListener('click', (e) => {
|
||||
const mode = e.currentTarget.dataset.mode;
|
||||
this.switchMode(mode);
|
||||
});
|
||||
});
|
||||
|
||||
// Canvas drawing events
|
||||
this.setupDrawingEvents();
|
||||
|
||||
// Control buttons
|
||||
const recognizeBtn = this.container.querySelector('#recognizeBtn');
|
||||
const clearBtn = this.container.querySelector('#clearBtn');
|
||||
const submitBtn = this.container.querySelector('#submitBtn');
|
||||
|
||||
if (recognizeBtn) {
|
||||
recognizeBtn.addEventListener('click', () => this.recognizeHandwriting());
|
||||
}
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => this.clearInput());
|
||||
}
|
||||
|
||||
if (submitBtn) {
|
||||
submitBtn.addEventListener('click', () => this.submitAnswer());
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
this.textArea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.submitAnswer();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupDrawingEvents() {
|
||||
// Touch/Mouse events for handwriting
|
||||
const startDrawing = (e) => {
|
||||
if (this.currentMode !== 'handwriting') return;
|
||||
|
||||
this.isDrawing = true;
|
||||
this.currentStroke = [];
|
||||
|
||||
const point = this.getCanvasPoint(e);
|
||||
this.currentStroke.push(point);
|
||||
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(point.x, point.y);
|
||||
};
|
||||
|
||||
const continueDrawing = (e) => {
|
||||
if (!this.isDrawing || this.currentMode !== 'handwriting') return;
|
||||
|
||||
const point = this.getCanvasPoint(e);
|
||||
this.currentStroke.push(point);
|
||||
|
||||
this.ctx.lineTo(point.x, point.y);
|
||||
this.ctx.stroke();
|
||||
};
|
||||
|
||||
const stopDrawing = () => {
|
||||
if (!this.isDrawing || this.currentMode !== 'handwriting') return;
|
||||
|
||||
this.isDrawing = false;
|
||||
|
||||
if (this.currentStroke.length > 0) {
|
||||
this.strokes.push([...this.currentStroke]);
|
||||
console.log(`✏️ Stroke completed. Total strokes: ${this.strokes.length}`);
|
||||
|
||||
// Auto-recognize after a delay
|
||||
this.scheduleAutoRecognition();
|
||||
}
|
||||
|
||||
this.currentStroke = [];
|
||||
};
|
||||
|
||||
// Mouse events
|
||||
this.canvas.addEventListener('mousedown', startDrawing);
|
||||
this.canvas.addEventListener('mousemove', continueDrawing);
|
||||
this.canvas.addEventListener('mouseup', stopDrawing);
|
||||
this.canvas.addEventListener('mouseout', stopDrawing);
|
||||
|
||||
// Touch events
|
||||
this.canvas.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
startDrawing(e.touches[0]);
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
continueDrawing(e.touches[0]);
|
||||
});
|
||||
|
||||
this.canvas.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
stopDrawing();
|
||||
});
|
||||
|
||||
// Pointer events (for stylus)
|
||||
if ('PointerEvent' in window) {
|
||||
this.canvas.addEventListener('pointerdown', startDrawing);
|
||||
this.canvas.addEventListener('pointermove', continueDrawing);
|
||||
this.canvas.addEventListener('pointerup', stopDrawing);
|
||||
}
|
||||
}
|
||||
|
||||
getCanvasPoint(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (e.clientX - rect.left) * (this.canvas.width / rect.width),
|
||||
y: (e.clientY - rect.top) * (this.canvas.height / rect.height),
|
||||
timestamp: Date.now(),
|
||||
pressure: e.pressure || 0.5,
|
||||
force: e.force || 0.5
|
||||
};
|
||||
}
|
||||
|
||||
switchMode(mode) {
|
||||
console.log(`🔄 Switching to ${mode} mode`);
|
||||
|
||||
this.currentMode = mode;
|
||||
|
||||
// Update button states
|
||||
const buttons = this.container.querySelectorAll('.mode-button');
|
||||
buttons.forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.mode === mode);
|
||||
});
|
||||
|
||||
// Update container class
|
||||
this.canvasContainer.className = `large-writing-canvas ${mode}-mode`;
|
||||
|
||||
// Update indicator
|
||||
if (mode === 'keyboard') {
|
||||
this.indicator.textContent = '⌨️ Keyboard Mode';
|
||||
this.container.querySelector('#recognizeBtn').style.display = 'none';
|
||||
} else {
|
||||
this.indicator.textContent = '✏️ Handwriting Mode';
|
||||
this.container.querySelector('#recognizeBtn').style.display = 'flex';
|
||||
}
|
||||
|
||||
// Clear any existing content when switching modes
|
||||
this.clearInput();
|
||||
|
||||
// Focus appropriate input
|
||||
if (mode === 'keyboard') {
|
||||
setTimeout(() => this.textArea.focus(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
async initializeHandwritingRecognition() {
|
||||
try {
|
||||
// Use existing handwriting recognition if available
|
||||
if (window.handwritingRecognition) {
|
||||
this.handwritingRecognizer = window.handwritingRecognition;
|
||||
console.log('✅ Using existing handwriting recognition');
|
||||
} else if (window.HandwritingRecognition) {
|
||||
this.handwritingRecognizer = new window.HandwritingRecognition();
|
||||
console.log('✅ Created new handwriting recognition instance');
|
||||
} else {
|
||||
console.log('⚠️ No handwriting recognition available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize handwriting recognition:', error);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleAutoRecognition() {
|
||||
// Clear any existing timeout
|
||||
if (this.autoRecognitionTimeout) {
|
||||
clearTimeout(this.autoRecognitionTimeout);
|
||||
}
|
||||
|
||||
// Schedule recognition after 2 seconds of no drawing
|
||||
this.autoRecognitionTimeout = setTimeout(() => {
|
||||
if (this.strokes.length > 0 && !this.isDrawing) {
|
||||
this.recognizeHandwriting();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
async recognizeHandwriting() {
|
||||
if (this.strokes.length === 0) {
|
||||
this.showFeedback('error', 'Please draw something first!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isRecognizing) {
|
||||
console.log('⚠️ Recognition already in progress');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRecognizing = true;
|
||||
this.showFeedback('processing', 'Recognizing handwriting...');
|
||||
|
||||
try {
|
||||
let recognizedText = null;
|
||||
|
||||
// Try different recognition methods
|
||||
if (this.handwritingRecognizer) {
|
||||
// Method 1: Use existing handwriting recognizer
|
||||
if (typeof this.handwritingRecognizer.recognizeStrokes === 'function') {
|
||||
recognizedText = await this.handwritingRecognizer.recognizeStrokes(this.strokes);
|
||||
} else if (typeof this.handwritingRecognizer.recognizeText === 'function') {
|
||||
// Temporarily set strokes and recognize
|
||||
const oldStrokes = this.handwritingRecognizer.strokes;
|
||||
this.handwritingRecognizer.strokes = this.strokes;
|
||||
recognizedText = await this.handwritingRecognizer.recognizeText();
|
||||
this.handwritingRecognizer.strokes = oldStrokes;
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Simple pattern recognition fallback
|
||||
if (!recognizedText) {
|
||||
recognizedText = this.simplePatternRecognition();
|
||||
}
|
||||
|
||||
if (recognizedText) {
|
||||
this.recognizedText = recognizedText;
|
||||
this.textArea.value = recognizedText;
|
||||
this.showFeedback('success', `Recognized: "${recognizedText}"`);
|
||||
console.log('✅ Handwriting recognition successful:', recognizedText);
|
||||
} else {
|
||||
this.showFeedback('error', 'Could not recognize handwriting. Please try writing more clearly.');
|
||||
console.log('❌ Handwriting recognition failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Recognition error:', error);
|
||||
this.showFeedback('error', 'Recognition failed. Please try again.');
|
||||
} finally {
|
||||
this.isRecognizing = false;
|
||||
}
|
||||
}
|
||||
|
||||
simplePatternRecognition() {
|
||||
// Simple fallback based on stroke patterns
|
||||
const strokeCount = this.strokes.length;
|
||||
|
||||
console.log(`🔍 Simple pattern recognition: ${strokeCount} strokes`);
|
||||
|
||||
if (strokeCount === 1) {
|
||||
return 'O';
|
||||
} else if (strokeCount === 2) {
|
||||
return 'H';
|
||||
} else if (strokeCount === 3) {
|
||||
return 'Boy';
|
||||
} else if (strokeCount >= 4 && strokeCount <= 6) {
|
||||
return 'Love';
|
||||
} else if (strokeCount > 6) {
|
||||
return 'Hello';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
clearInput() {
|
||||
// Clear text
|
||||
this.textArea.value = '';
|
||||
this.recognizedText = '';
|
||||
|
||||
// Clear canvas
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.strokes = [];
|
||||
this.currentStroke = [];
|
||||
|
||||
// Clear feedback
|
||||
this.showFeedback('', '');
|
||||
|
||||
// Clear auto-recognition timeout
|
||||
if (this.autoRecognitionTimeout) {
|
||||
clearTimeout(this.autoRecognitionTimeout);
|
||||
}
|
||||
|
||||
console.log('🧹 Input cleared');
|
||||
}
|
||||
|
||||
getCurrentText() {
|
||||
return this.textArea.value.trim();
|
||||
}
|
||||
|
||||
submitAnswer() {
|
||||
const answer = this.getCurrentText();
|
||||
|
||||
if (!answer) {
|
||||
this.showFeedback('error', 'Please enter an answer first!');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📝 Submitting answer:', answer);
|
||||
|
||||
// Use existing game submission logic if available
|
||||
if (window.submitAnswer) {
|
||||
window.submitAnswer(answer);
|
||||
} else if (window.checkAnswer) {
|
||||
window.checkAnswer(answer);
|
||||
} else {
|
||||
// Fallback - just show success
|
||||
this.showFeedback('success', `Answer submitted: "${answer}"`);
|
||||
|
||||
// Trigger custom event for other systems to listen
|
||||
const event = new CustomEvent('unifiedWriteSubmit', {
|
||||
detail: { answer, mode: this.currentMode }
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// Clear after submission
|
||||
setTimeout(() => this.clearInput(), 2000);
|
||||
}
|
||||
|
||||
showFeedback(type, message) {
|
||||
const feedback = this.container.querySelector('#recognitionFeedback');
|
||||
feedback.className = `recognition-feedback ${type}`;
|
||||
feedback.textContent = message;
|
||||
|
||||
// Auto-clear success/error messages
|
||||
if (type === 'success' || type === 'error') {
|
||||
setTimeout(() => {
|
||||
if (feedback.textContent === message) {
|
||||
feedback.textContent = '';
|
||||
feedback.className = 'recognition-feedback';
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Public methods for external integration
|
||||
setText(text) {
|
||||
this.textArea.value = text;
|
||||
}
|
||||
|
||||
getText() {
|
||||
return this.getCurrentText();
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
this.switchMode(mode);
|
||||
}
|
||||
|
||||
getMode() {
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
setupGameIntegration() {
|
||||
// Hook into existing submit button if it exists
|
||||
const submitBtn = document.querySelector('button[onclick="submitAnswer()"]') ||
|
||||
document.getElementById('submitBtn');
|
||||
|
||||
if (submitBtn) {
|
||||
// Remove existing onclick and add new handler
|
||||
submitBtn.removeAttribute('onclick');
|
||||
submitBtn.addEventListener('click', () => {
|
||||
if (window.submitAnswer) {
|
||||
window.submitAnswer(this.getText());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add Enter key support for keyboard mode
|
||||
this.canvas.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter' && this.currentMode === 'keyboard') {
|
||||
if (window.submitAnswer) {
|
||||
window.submitAnswer(this.getText());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Wait a bit for other systems to initialize
|
||||
setTimeout(() => {
|
||||
window.unifiedWriteMode = new UnifiedWriteMode();
|
||||
if (window.unifiedWriteMode.setupGameIntegration) {
|
||||
window.unifiedWriteMode.setupGameIntegration();
|
||||
}
|
||||
console.log('✅ Unified Write Mode initialized and integrated');
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Export for use in other scripts
|
||||
window.UnifiedWriteMode = UnifiedWriteMode;
|
||||
@@ -0,0 +1,94 @@
|
||||
/* Native Handwriting Recognition Status Styles */
|
||||
|
||||
.native-recognition-status {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
margin: 8px 0;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||
animation: statusPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.native-recognition-status.native-supported {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
}
|
||||
|
||||
.native-recognition-status.fallback {
|
||||
background: linear-gradient(135deg, #ffeaa7 0%, #fab1a0 100%);
|
||||
color: #2d3436;
|
||||
}
|
||||
|
||||
.native-recognition-status .status-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.recognition-method-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.recognition-method-info .method-badge {
|
||||
background: #e9ecef;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recognition-method-info .method-badge.native {
|
||||
background: linear-gradient(135deg, #a8e6cf 0%, #7fcdcd 100%);
|
||||
color: #2d3436;
|
||||
}
|
||||
|
||||
.recognition-method-info .method-badge.enhanced {
|
||||
background: linear-gradient(135deg, #ffd3a5 0%, #fd9853 100%);
|
||||
color: #2d3436;
|
||||
}
|
||||
|
||||
@keyframes statusPulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.02); opacity: 0.9; }
|
||||
}
|
||||
|
||||
/* Tablet optimizations for recognition status */
|
||||
@media (min-width: 768px) {
|
||||
.native-recognition-status {
|
||||
font-size: 1rem;
|
||||
padding: 12px 20px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.recognition-method-info {
|
||||
font-size: 0.9rem;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.recognition-method-info .method-badge {
|
||||
padding: 4px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced handwriting canvas for tablet use */
|
||||
@media (min-width: 768px) {
|
||||
#handwritingCanvas {
|
||||
border: 3px solid #e1e8ed;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
background: #fafbfc;
|
||||
cursor: crosshair;
|
||||
touch-action: none; /* Prevent default touch behaviors */
|
||||
}
|
||||
|
||||
#handwritingCanvas:active {
|
||||
border-color: #1da1f2;
|
||||
box-shadow: 0 4px 20px rgba(29, 161, 242, 0.2);
|
||||
}
|
||||
}
|
||||
+271
-34
@@ -34,22 +34,59 @@ body {
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
padding: 2rem 1rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0,0,0,0);
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Container and layout */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
padding: clamp(0.5rem, 1vw, 1rem);
|
||||
}
|
||||
|
||||
/* Responsive layout utilities */
|
||||
@media (max-width: 768px) {
|
||||
.overview-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 1rem !important;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 1rem !important;
|
||||
padding: 1.5rem !important;
|
||||
aspect-ratio: auto !important;
|
||||
min-height: 120px !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 1rem 0 !important;
|
||||
padding: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.overview-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-size: clamp(2rem, 6vw, 4rem);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: clamp(2rem, 4vw, 3rem);
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
@@ -57,44 +94,53 @@ h1 {
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.875rem;
|
||||
font-size: clamp(1.25rem, 4vw, 2rem);
|
||||
font-weight: 600;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: clamp(1.5rem, 3vw, 2.5rem);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
font-size: clamp(1rem, 3vw, 1.5rem);
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: clamp(1rem, 2vw, 1.5rem);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0.5rem;
|
||||
max-width: min(400px, 90vw);
|
||||
padding: clamp(0.75rem, 2vw, 1.25rem) clamp(0.875rem, 2.5vw, 1.5rem);
|
||||
margin: clamp(0.5rem, 1.5vw, 1rem);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
border-radius: clamp(6px, 1.5vw, 12px);
|
||||
font-size: clamp(1rem, 2.5vw, 1.25rem);
|
||||
font-family: inherit;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: var(--transition);
|
||||
min-height: clamp(44px, 8vw, 60px); /* Responsive touch target */
|
||||
touch-action: manipulation; /* Prevent zoom on focus */
|
||||
}
|
||||
|
||||
/* Special styling for game input */
|
||||
#userInput {
|
||||
font-size: 1.2rem;
|
||||
max-width: 400px;
|
||||
font-size: 1.5rem;
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
background-color: white !important;
|
||||
color: var(--text-primary) !important;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
caret-color: var(--primary);
|
||||
min-height: 60px;
|
||||
padding: 1.25rem !important;
|
||||
border: 3px solid var(--primary) !important;
|
||||
border-radius: 12px !important;
|
||||
margin: 1.5rem auto !important;
|
||||
display: block;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2) !important;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
@@ -113,14 +159,15 @@ textarea {
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
margin: 0.25rem;
|
||||
justify-content: center;
|
||||
gap: clamp(0.25rem, 1vw, 0.75rem);
|
||||
padding: clamp(0.75rem, 2vw, 1.25rem) clamp(1rem, 3vw, 2.5rem);
|
||||
margin: clamp(0.75rem, 2vw, 1.25rem);
|
||||
background: var(--primary);
|
||||
color: #ffffff !important;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
border-radius: clamp(8px, 2vw, 16px);
|
||||
font-size: clamp(0.875rem, 2.5vw, 1.25rem);
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
@@ -128,7 +175,10 @@ button {
|
||||
box-shadow: var(--shadow);
|
||||
text-decoration: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
min-height: 44px;
|
||||
min-height: clamp(44px, 8vw, 60px);
|
||||
min-width: clamp(100px, 20vw, 150px);
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
@@ -370,11 +420,23 @@ button:disabled {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Admin panel - Full width */
|
||||
#adminPanel {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
#adminPanel .card {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
#loginPanel form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
gap: 2rem;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Banner */
|
||||
@@ -399,8 +461,8 @@ button:disabled {
|
||||
/* Navigation */
|
||||
#nav {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: clamp(1.5rem, 3vw, 2.5rem);
|
||||
padding: clamp(1rem, 2vw, 1.5rem);
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
@@ -410,9 +472,9 @@ button:disabled {
|
||||
#adminTabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
padding: 0.75rem;
|
||||
gap: clamp(0.75rem, 2vw, 1rem);
|
||||
margin-bottom: clamp(1.5rem, 3vw, 2.5rem);
|
||||
padding: clamp(1rem, 2vw, 1.5rem);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
@@ -444,7 +506,19 @@ button:disabled {
|
||||
.adminTab {
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius);
|
||||
padding: 2rem;
|
||||
padding: 2.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Cards - Full width layout */
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 2.5rem 0;
|
||||
padding: 3rem;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
@@ -458,18 +532,36 @@ button:disabled {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Global section spacing for better breathing room */
|
||||
div[id*="Section"],
|
||||
div[id*="Panel"],
|
||||
div[id*="Tab"],
|
||||
#gameArea,
|
||||
#spellingGame,
|
||||
#results {
|
||||
margin: 2.5rem 0;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Game elements */
|
||||
#wordBox, .word-box, #typingPrompt {
|
||||
font-size: 3rem;
|
||||
font-size: clamp(2.5rem, 8vw, 4rem);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
margin: 2rem 0;
|
||||
margin: 2rem auto;
|
||||
max-width: 700px;
|
||||
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--secondary) 100%);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
border: 3px solid var(--primary);
|
||||
border-radius: 20px;
|
||||
color: var(--primary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 2px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Word feedback styles */
|
||||
@@ -578,11 +670,47 @@ button:disabled {
|
||||
/* Student controls specific styling */
|
||||
.student-controls button {
|
||||
min-height: 60px !important;
|
||||
font-size: 1.1rem !important;
|
||||
font-size: 1.25rem !important;
|
||||
font-weight: 600 !important;
|
||||
text-shadow: none !important;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
||||
padding: 1.5rem 2rem !important;
|
||||
}
|
||||
|
||||
/* Activity options for tablet */
|
||||
.activity-option {
|
||||
background: var(--bg-primary) !important;
|
||||
border-radius: 16px !important;
|
||||
padding: 2rem !important;
|
||||
text-align: center !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
|
||||
transition: all 0.2s ease !important;
|
||||
cursor: pointer !important;
|
||||
border: 2px solid var(--border) !important;
|
||||
min-height: 200px !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
justify-content: space-between !important;
|
||||
}
|
||||
|
||||
.activity-option:hover, .activity-option:active {
|
||||
transform: translateY(-4px) !important;
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15) !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.activity-option h3 {
|
||||
font-size: 1.5rem !important;
|
||||
margin: 0.5rem 0 !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.activity-option p {
|
||||
font-size: 1rem !important;
|
||||
color: var(--text-secondary) !important;
|
||||
margin-bottom: 1rem !important;
|
||||
line-height: 1.4 !important;
|
||||
}
|
||||
|
||||
.student-controls .btn-success {
|
||||
@@ -688,3 +816,112 @@ button[id*="debug"],
|
||||
[data-debug="true"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Tablet and handwriting enhancements */
|
||||
/* Input mode toggle styles */
|
||||
.input-mode-toggle {
|
||||
display: flex !important;
|
||||
background: var(--secondary) !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 0.5rem !important;
|
||||
margin: 1.5rem auto !important;
|
||||
max-width: 350px !important;
|
||||
position: relative !important;
|
||||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1) !important;
|
||||
}
|
||||
|
||||
.input-mode-toggle button {
|
||||
flex: 1 !important;
|
||||
padding: 1rem 1.5rem !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
color: var(--text-secondary) !important;
|
||||
font-weight: 600 !important;
|
||||
border-radius: 8px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.2s ease !important;
|
||||
font-size: 1.125rem !important;
|
||||
min-height: 50px !important;
|
||||
margin: 0 !important;
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
.input-mode-toggle button.active {
|
||||
background: var(--bg-primary) !important;
|
||||
color: var(--primary) !important;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15) !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.input-mode-toggle button:not(.active):hover {
|
||||
color: var(--text-primary) !important;
|
||||
background: rgba(255,255,255,0.5) !important;
|
||||
}
|
||||
|
||||
/* Handwriting container styles */
|
||||
.handwriting-container {
|
||||
background: var(--bg-primary) !important;
|
||||
border-radius: 16px !important;
|
||||
padding: 2rem !important;
|
||||
margin: 2rem auto !important;
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.1) !important;
|
||||
border: 2px solid var(--border) !important;
|
||||
max-width: 700px !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.handwriting-container h4 {
|
||||
color: var(--text-primary) !important;
|
||||
font-size: 1.25rem !important;
|
||||
margin-bottom: 1.5rem !important;
|
||||
}
|
||||
|
||||
#handwritingCanvas {
|
||||
border: 3px solid var(--primary) !important;
|
||||
border-radius: 12px !important;
|
||||
background: white !important;
|
||||
cursor: crosshair !important;
|
||||
display: block !important;
|
||||
margin: 1rem auto !important;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1) !important;
|
||||
touch-action: none !important;
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
max-width: 600px !important;
|
||||
height: 250px !important;
|
||||
}
|
||||
|
||||
/* Canvas controls */
|
||||
.canvas-controls {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
gap: 1rem !important;
|
||||
margin: 1rem 0 !important;
|
||||
flex-wrap: wrap !important;
|
||||
}
|
||||
|
||||
.canvas-controls button {
|
||||
padding: 1rem 2rem !important;
|
||||
font-size: 1.125rem !important;
|
||||
min-height: 50px !important;
|
||||
min-width: 120px !important;
|
||||
border-radius: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
margin: 0.25rem !important;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: var(--error) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.recognize-btn {
|
||||
background: var(--success) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.undo-btn {
|
||||
background: var(--warning) !important;
|
||||
color: white !important;
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
/* Clean Tablet Menu System - Starting Fresh */
|
||||
|
||||
/* Hide original navigation */
|
||||
#nav {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Hamburger Menu Toggle - Position at Top Right */
|
||||
.menu-toggle {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1001;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 8px;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu-toggle:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.menu-toggle.active {
|
||||
background: var(--error);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* Popup Menu Container */
|
||||
.menu-popup {
|
||||
position: fixed;
|
||||
width: 80vw;
|
||||
max-width: 1000px;
|
||||
max-height: 90vh;
|
||||
background: var(--bg-primary);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.menu-popup.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Popup Header */
|
||||
.popup-header {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
cursor: move;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.popup-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.minimize-btn, .close-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.minimize-btn:hover, .close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Popup Content */
|
||||
.popup-content {
|
||||
padding: 1.5rem;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Admin Grid - Stacked Single Column Layout */
|
||||
.admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Gray Cards/Boxes */
|
||||
.card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow);
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.card h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Admin Tabs */
|
||||
.adminTab {
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.adminTab.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Form Elements */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
margin: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--success) !important;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.btn-error {
|
||||
background: var(--error) !important;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.admin-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.menu-popup {
|
||||
width: 95vw;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.admin-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Admin Dashboard Stats */
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2rem;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Student Dashboard */
|
||||
.quick-actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.instruction-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Stack navigation buttons vertically */
|
||||
.nav-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-list-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Draggable Cards Styles */
|
||||
.card.dragging {
|
||||
transition: none !important;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-header:hover .drag-indicator {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Global drag mode styles - works on all pages */
|
||||
.global-drag-mode .resize-handle {
|
||||
opacity: 0.7 !important;
|
||||
}
|
||||
|
||||
.global-drag-mode .resize-handle:hover {
|
||||
opacity: 1 !important;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.global-drag-mode .card-header {
|
||||
cursor: move !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
body:not(.global-drag-mode) .card-header {
|
||||
cursor: default;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.global-drag-mode .card-header:hover {
|
||||
background: color-mix(in srgb, var(--primary) 80%, white 20%) !important;
|
||||
}
|
||||
|
||||
.global-drag-mode .card {
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.global-drag-mode .card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1) !important;
|
||||
}
|
||||
|
||||
/* Global card controls */
|
||||
.global-card-controls {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.global-card-controls .btn {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.global-card-controls .btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Legacy admin-grid support */
|
||||
.admin-grid.drag-mode .resize-handle {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.admin-grid.drag-mode .resize-handle:hover {
|
||||
opacity: 1 !important;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.admin-grid.drag-mode .card-header {
|
||||
cursor: move;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.admin-grid:not(.drag-mode) .card-header {
|
||||
cursor: default;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.admin-grid.drag-mode .card-header:hover {
|
||||
background: color-mix(in srgb, var(--primary) 80%, white 20%);
|
||||
}
|
||||
|
||||
/* Admin Grid Modifications for Drag & Drop */
|
||||
.admin-grid.drag-mode {
|
||||
/* Remove grid layout when in drag mode */
|
||||
display: block !important;
|
||||
position: relative;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.admin-grid.drag-mode .card {
|
||||
position: absolute;
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.admin-grid.drag-mode .card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Card Controls */
|
||||
.card-controls {
|
||||
background: rgba(255,255,255,0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
/* Tablet-optimized styles for Spelling Practice App */
|
||||
|
||||
/* Tablet and touch device optimizations */
|
||||
@media screen and (min-width: 600px), (pointer: coarse) {
|
||||
|
||||
/* Enhanced touch targets and spacing */
|
||||
:root {
|
||||
--touch-target: 48px; /* Minimum touch target size */
|
||||
--tablet-padding: 2rem;
|
||||
--tablet-margin: 1.5rem;
|
||||
--tablet-font-large: 2.5rem;
|
||||
--tablet-font-medium: 1.5rem;
|
||||
--tablet-font-small: 1.125rem;
|
||||
}
|
||||
|
||||
/* Body and container adjustments */
|
||||
body {
|
||||
padding: var(--tablet-padding);
|
||||
font-size: var(--tablet-font-small);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 98vw;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
/* Enhanced typography for tablet viewing */
|
||||
h1 {
|
||||
font-size: var(--tablet-font-large);
|
||||
margin-bottom: 2.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2.25rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Enhanced buttons for touch interaction */
|
||||
button {
|
||||
min-height: var(--touch-target);
|
||||
min-width: var(--touch-target);
|
||||
padding: 1rem 2rem;
|
||||
font-size: var(--tablet-font-small);
|
||||
margin: 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
button:hover, button:focus {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Primary buttons get special treatment */
|
||||
button.primary, #startGameBtn, #startTypingBtn, #submitAnswer {
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
padding: 1.25rem 2.5rem;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* Enhanced form controls for touch */
|
||||
input, textarea, select {
|
||||
min-height: var(--touch-target);
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: var(--tablet-font-small);
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--border);
|
||||
margin: 0.75rem;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
/* Special styling for game input */
|
||||
#userInput {
|
||||
font-size: 1.75rem;
|
||||
min-height: 60px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
max-width: 600px;
|
||||
border: 3px solid var(--primary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Word display optimization */
|
||||
#wordBox {
|
||||
font-size: 3.5rem;
|
||||
padding: 2rem;
|
||||
margin: 2rem auto;
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--secondary) 100%);
|
||||
box-shadow: var(--shadow-lg);
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Navigation improvements */
|
||||
#nav {
|
||||
padding: 1.5rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#nav button {
|
||||
min-width: 120px;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Admin tabs for tablet */
|
||||
#adminTabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#adminTabs button {
|
||||
min-height: 50px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 25px;
|
||||
}
|
||||
|
||||
/* Card layouts optimized for tablet */
|
||||
.card, .adminTab {
|
||||
padding: 2rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Game section enhancements */
|
||||
#gameSection {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#inputSection {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Typing section improvements */
|
||||
#typingSection {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
#bibleVerseDisplay {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.8;
|
||||
padding: 2rem;
|
||||
margin: 2rem 0;
|
||||
border-radius: 16px;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
#bibleInput, #typingInput {
|
||||
min-height: 200px;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.6;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Activity options layout */
|
||||
#activityOptions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.activity-option {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow);
|
||||
transition: var(--transition);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.activity-option:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Summary and results improvements */
|
||||
#summary {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 20px;
|
||||
padding: 3rem;
|
||||
margin: 2rem auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#summary h2 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
#summary p {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Login panel optimizations */
|
||||
#loginPanel {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
#loginPanel h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
#loginPanel input {
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
#loginPanel button {
|
||||
width: 100%;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* User dropdown improvements */
|
||||
#userDropdown select {
|
||||
min-height: 50px;
|
||||
font-size: 1.125rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Progress indicators */
|
||||
.progress-bar {
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Feedback messages */
|
||||
.feedback-correct, .feedback-incorrect {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.feedback-correct {
|
||||
background-color: #f0fdf4;
|
||||
color: var(--success);
|
||||
border: 2px solid var(--success);
|
||||
}
|
||||
|
||||
.feedback-incorrect {
|
||||
background-color: #fef2f2;
|
||||
color: var(--error);
|
||||
border: 2px solid var(--error);
|
||||
}
|
||||
|
||||
/* Badge display improvements */
|
||||
.badge {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 25px;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
/* Charts and analytics */
|
||||
.chart-container {
|
||||
margin: 2rem 0;
|
||||
padding: 2rem;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
/* Responsive grid layouts */
|
||||
.tablet-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.tablet-grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Utility classes for tablet */
|
||||
.tablet-center {
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tablet-large-text {
|
||||
font-size: var(--tablet-font-medium);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tablet-padding {
|
||||
padding: var(--tablet-padding);
|
||||
}
|
||||
|
||||
.tablet-margin {
|
||||
margin: var(--tablet-margin);
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape tablet specific adjustments */
|
||||
@media screen and (min-width: 768px) and (max-width: 1024px) and (orientation: landscape) {
|
||||
|
||||
body {
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
#wordBox {
|
||||
font-size: 3rem;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem auto;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
#gameSection, #typingSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
#nav {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.tablet-grid-3 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Portrait tablet specific adjustments */
|
||||
@media screen and (min-width: 768px) and (max-width: 1024px) and (orientation: portrait) {
|
||||
|
||||
#activityOptions {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 400px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.tablet-grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tablet-grid-3 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/* Unified Write Mode - Large Canvas + Keyboard Support */
|
||||
|
||||
.unified-write-mode {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.large-writing-canvas {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
height: 40vh;
|
||||
border: 3px solid #e1e8ed;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
|
||||
box-shadow:
|
||||
inset 0 2px 4px rgba(0,0,0,0.06),
|
||||
0 4px 20px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
cursor: text;
|
||||
touch-action: none;
|
||||
font-family: 'Comic Sans MS', cursive, sans-serif;
|
||||
font-size: 32px;
|
||||
line-height: 1.4;
|
||||
padding: 20px;
|
||||
color: #2c3e50;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.large-writing-canvas:focus-within {
|
||||
border-color: #3b82f6;
|
||||
box-shadow:
|
||||
inset 0 2px 4px rgba(0,0,0,0.06),
|
||||
0 4px 20px rgba(59, 130, 246, 0.2),
|
||||
0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.canvas-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.canvas-overlay.active {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.text-input-area {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
resize: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.text-input-area::placeholder {
|
||||
color: #94a3b8;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.input-mode-selector {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mode-button {
|
||||
padding: 12px 24px;
|
||||
border: 2px solid #e1e8ed;
|
||||
border-radius: 25px;
|
||||
background: white;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.mode-button:hover {
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.mode-button.active {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
border-color: #1d4ed8;
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.mode-button .icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.canvas-controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.canvas-controls .btn {
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-recognize {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-recognize:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-clear:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
padding: 14px 28px;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
|
||||
.recognition-feedback {
|
||||
margin-top: 15px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.recognition-feedback.success {
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||
color: #065f46;
|
||||
border: 1px solid #10b981;
|
||||
}
|
||||
|
||||
.recognition-feedback.processing {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
color: #92400e;
|
||||
border: 1px solid #f59e0b;
|
||||
}
|
||||
|
||||
.recognition-feedback.error {
|
||||
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
||||
color: #991b1b;
|
||||
border: 1px solid #ef4444;
|
||||
}
|
||||
|
||||
/* Tablet optimizations */
|
||||
@media (min-width: 768px) {
|
||||
.large-writing-canvas {
|
||||
min-height: 400px;
|
||||
height: 50vh;
|
||||
font-size: 36px;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.mode-button {
|
||||
padding: 16px 32px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.canvas-controls .btn {
|
||||
padding: 16px 28px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
font-size: 18px;
|
||||
padding: 18px 36px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large tablets and desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.large-writing-canvas {
|
||||
min-height: 450px;
|
||||
height: 55vh;
|
||||
font-size: 40px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.unified-write-mode {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stylus and fine pointer support */
|
||||
@media (pointer: fine) {
|
||||
.large-writing-canvas {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.text-input-area {
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
/* Handwriting mode specific styles */
|
||||
.handwriting-mode .text-input-area {
|
||||
pointer-events: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.handwriting-mode .canvas-overlay {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* Keyboard mode specific styles */
|
||||
.keyboard-mode .canvas-overlay {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.keyboard-mode .text-input-area {
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Visual feedback for active mode */
|
||||
.handwriting-mode .large-writing-canvas {
|
||||
background: linear-gradient(145deg, #f0f9ff 0%, #e0f2fe 100%);
|
||||
}
|
||||
|
||||
.keyboard-mode .large-writing-canvas {
|
||||
background: linear-gradient(145deg, #f0fdf4 0%, #dcfce7 100%);
|
||||
}
|
||||
|
||||
/* Input method indicator */
|
||||
.input-method-indicator {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 20px;
|
||||
background: rgba(0,0,0,0.1);
|
||||
color: #64748b;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.handwriting-mode .input-method-indicator {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.keyboard-mode .input-method-indicator {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
}
|
||||
@@ -490,27 +490,58 @@ app.post('/verifyUser', (req, res) => {
|
||||
|
||||
// ➕ Add user
|
||||
app.post('/addUser', (req, res) => {
|
||||
const { username, hash, role } = req.body;
|
||||
if (
|
||||
typeof username !== 'string' ||
|
||||
typeof hash !== 'string' ||
|
||||
typeof role !== 'string' ||
|
||||
!['admin', 'student'].includes(role.trim().toLowerCase())
|
||||
) return res.status(400).send("Invalid user data");
|
||||
try {
|
||||
console.log(`📥 Received addUser request:`, req.body);
|
||||
|
||||
const users = readJsonSafe(path.join(DATA_DIR, files.users), []);
|
||||
if (users.find(u => u.username === username)) {
|
||||
return res.status(409).send("User already exists");
|
||||
const { username, hash, role } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!username || !hash || !role) {
|
||||
console.error(`❌ Missing required fields: ${!username ? 'username' : ''} ${!hash ? 'hash' : ''} ${!role ? 'role' : ''}`);
|
||||
return res.status(400).send("Missing required fields");
|
||||
}
|
||||
|
||||
// Validate data types and values
|
||||
if (
|
||||
typeof username !== 'string' ||
|
||||
typeof hash !== 'string' ||
|
||||
typeof role !== 'string' ||
|
||||
!['admin', 'student'].includes(role.trim().toLowerCase())
|
||||
) {
|
||||
console.error(`❌ Invalid user data types or values`);
|
||||
return res.status(400).send("Invalid user data");
|
||||
}
|
||||
|
||||
// Trim inputs
|
||||
const trimmedUsername = username.trim();
|
||||
const trimmedRole = role.trim().toLowerCase();
|
||||
|
||||
// Check for existing user
|
||||
const usersPath = path.join(DATA_DIR, files.users);
|
||||
const users = readJsonSafe(usersPath, []);
|
||||
if (users.find(u => u.username === trimmedUsername)) {
|
||||
console.error(`❌ User "${trimmedUsername}" already exists`);
|
||||
return res.status(409).send(`User "${trimmedUsername}" already exists`);
|
||||
}
|
||||
|
||||
console.log(`✏️ Adding new ${trimmedRole} user: ${trimmedUsername}`);
|
||||
|
||||
// Add user to users.json
|
||||
users.push({ username: trimmedUsername, hash, role: trimmedRole });
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync(usersPath, JSON.stringify(users, null, 2));
|
||||
console.log(`✅ Added user to users.json: ${trimmedUsername}`);
|
||||
|
||||
// Initialize user in all other data files
|
||||
initializeUserInAllFiles(trimmedUsername);
|
||||
|
||||
console.log(`✅ User "${trimmedUsername}" successfully added and initialized`);
|
||||
res.send(`✅ User "${trimmedUsername}" added and initialized`);
|
||||
} catch (error) {
|
||||
console.error(`🔴 Error in /addUser:`, error);
|
||||
res.status(500).send(`Server error: ${error.message}`);
|
||||
}
|
||||
|
||||
// Add user to users.json
|
||||
users.push({ username: username.trim(), hash, role: role.trim() });
|
||||
fs.writeFileSync(path.join(DATA_DIR, files.users), JSON.stringify(users, null, 2));
|
||||
|
||||
// Initialize user in all other data files
|
||||
initializeUserInAllFiles(username.trim());
|
||||
|
||||
res.send(`✅ User "${username}" added and initialized`);
|
||||
});
|
||||
|
||||
// ❌ Delete user
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Tablet & Handwriting Test</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3b82f6;
|
||||
--primary-dark: #2563eb;
|
||||
--secondary: #f1f5f9;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-secondary);
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: var(--primary);
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
margin: 2rem 0;
|
||||
padding: 1.5rem;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.demo-section h2 {
|
||||
color: var(--text-primary);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
button {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 12px;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
min-height: 50px;
|
||||
min-width: 120px;
|
||||
margin: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
button:hover, button:active {
|
||||
background: var(--primary-dark);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Input mode toggle */
|
||||
.input-mode-toggle {
|
||||
display: flex;
|
||||
background: var(--secondary);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem;
|
||||
margin: 1.5rem auto;
|
||||
max-width: 350px;
|
||||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.input-mode-toggle button {
|
||||
flex: 1;
|
||||
padding: 1rem 1.5rem;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.125rem;
|
||||
min-height: 50px;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.input-mode-toggle button.active {
|
||||
background: var(--bg-primary);
|
||||
color: var(--primary);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
/* Text input */
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
background: white;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
min-height: 60px;
|
||||
padding: 1.25rem;
|
||||
border: 3px solid var(--primary);
|
||||
border-radius: 12px;
|
||||
margin: 1.5rem auto;
|
||||
display: block;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
/* Canvas styles */
|
||||
#handwritingCanvas {
|
||||
border: 3px solid var(--primary);
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
cursor: crosshair;
|
||||
display: block;
|
||||
margin: 1rem auto;
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
touch-action: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.canvas-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.canvas-controls button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: var(--error) !important;
|
||||
}
|
||||
|
||||
.recognize-btn {
|
||||
background: var(--success) !important;
|
||||
}
|
||||
|
||||
.recognition-status {
|
||||
margin: 1rem auto;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.recognition-status.success {
|
||||
background: #f0fdf4;
|
||||
color: var(--success);
|
||||
border: 2px solid var(--success);
|
||||
}
|
||||
|
||||
.recognition-status.error {
|
||||
background: #fef2f2;
|
||||
color: var(--error);
|
||||
border: 2px solid var(--error);
|
||||
}
|
||||
|
||||
.recognized-text {
|
||||
background: var(--secondary);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 1rem auto;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.recognized-text.has-content {
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.handwriting-container {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin: 2rem auto;
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
|
||||
border: 2px solid var(--border);
|
||||
max-width: 700px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📱 Tablet & Handwriting Test</h1>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2>🎮 Spelling Game Interface</h2>
|
||||
|
||||
<!-- Word Display -->
|
||||
<div style="font-size: 3rem; font-weight: 700; text-align: center; padding: 2rem; margin: 2rem auto; max-width: 600px; background: linear-gradient(135deg, #ffffff 0%, #f1f5f9 100%); border: 3px solid var(--primary); border-radius: 20px; color: var(--primary); box-shadow: var(--shadow-lg); min-height: 120px; display: flex; align-items: center; justify-content: center; letter-spacing: 2px;">
|
||||
EXAMPLE
|
||||
</div>
|
||||
|
||||
<!-- Input Mode Toggle -->
|
||||
<div class="input-mode-toggle">
|
||||
<button class="active" data-mode="typing" onclick="toggleMode('typing')">
|
||||
⌨️ Type
|
||||
</button>
|
||||
<button data-mode="handwriting" onclick="toggleMode('handwriting')">
|
||||
✏️ Write
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Typing Input -->
|
||||
<input type="text" id="typingInput" placeholder="Type the word you saw..." style="display: block;">
|
||||
|
||||
<!-- Handwriting Container -->
|
||||
<div id="handwritingContainer" class="handwriting-container" style="display: none;">
|
||||
<h3>✏️ Write the word you saw:</h3>
|
||||
|
||||
<!-- Canvas -->
|
||||
<canvas id="handwritingCanvas" width="600" height="250" style="width: 100%; max-width: 600px; height: 250px;"></canvas>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="canvas-controls">
|
||||
<button onclick="clearCanvas()" class="clear-btn">🗑️ Clear</button>
|
||||
<button onclick="recognizeText()" class="recognize-btn">🔍 Recognize</button>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div id="recognitionStatus" class="recognition-status" style="display: none;"></div>
|
||||
|
||||
<!-- Recognized Text -->
|
||||
<div id="recognizedText" class="recognized-text">Draw to see recognized text here</div>
|
||||
</div>
|
||||
|
||||
<button onclick="submitAnswer()" style="background: var(--success); font-size: 1.25rem; padding: 1.25rem 2.5rem; margin-top: 1rem;">Submit Answer</button>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2>📊 Test Results</h2>
|
||||
<div id="testResults" style="padding: 1rem; background: var(--secondary); border-radius: 8px; font-family: monospace;">
|
||||
Ready for testing...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simple handwriting canvas implementation
|
||||
let canvas, ctx, isDrawing = false, strokes = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
canvas = document.getElementById('handwritingCanvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
// Set canvas properties
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = '#1e293b';
|
||||
ctx.lineWidth = 3;
|
||||
|
||||
// Mouse events
|
||||
canvas.addEventListener('mousedown', startDrawing);
|
||||
canvas.addEventListener('mousemove', draw);
|
||||
canvas.addEventListener('mouseup', stopDrawing);
|
||||
|
||||
// Touch events
|
||||
canvas.addEventListener('touchstart', handleTouch);
|
||||
canvas.addEventListener('touchmove', handleTouch);
|
||||
canvas.addEventListener('touchend', stopDrawing);
|
||||
});
|
||||
|
||||
function handleTouch(e) {
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
|
||||
const touch = e.touches[0] || e.changedTouches[0];
|
||||
const mouseEvent = new MouseEvent(
|
||||
e.type === 'touchstart' ? 'mousedown' :
|
||||
e.type === 'touchmove' ? 'mousemove' : 'mouseup',
|
||||
{
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY
|
||||
}
|
||||
);
|
||||
|
||||
if (e.type === 'touchstart') startDrawing(mouseEvent);
|
||||
else if (e.type === 'touchmove') draw(mouseEvent);
|
||||
else stopDrawing();
|
||||
}
|
||||
|
||||
function startDrawing(e) {
|
||||
isDrawing = true;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
}
|
||||
|
||||
function draw(e) {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
|
||||
ctx.lineTo(x, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function stopDrawing() {
|
||||
isDrawing = false;
|
||||
}
|
||||
|
||||
function clearCanvas() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
document.getElementById('recognizedText').textContent = 'Draw to see recognized text here';
|
||||
document.getElementById('recognizedText').className = 'recognized-text';
|
||||
updateTestResults('Canvas cleared');
|
||||
}
|
||||
|
||||
function recognizeText() {
|
||||
// Simple mock recognition
|
||||
const mockWords = ['EXAMPLE', 'TEST', 'HELLO', 'WORLD', 'TABLET'];
|
||||
const recognized = mockWords[Math.floor(Math.random() * mockWords.length)];
|
||||
|
||||
document.getElementById('recognizedText').textContent = recognized;
|
||||
document.getElementById('recognizedText').className = 'recognized-text has-content';
|
||||
|
||||
updateTestResults(`Recognized: "${recognized}"`);
|
||||
}
|
||||
|
||||
function toggleMode(mode) {
|
||||
const buttons = document.querySelectorAll('.input-mode-toggle button');
|
||||
const typingInput = document.getElementById('typingInput');
|
||||
const handwritingContainer = document.getElementById('handwritingContainer');
|
||||
|
||||
buttons.forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
if (btn.dataset.mode === mode) {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
if (mode === 'typing') {
|
||||
typingInput.style.display = 'block';
|
||||
handwritingContainer.style.display = 'none';
|
||||
updateTestResults('Switched to typing mode');
|
||||
} else {
|
||||
typingInput.style.display = 'none';
|
||||
handwritingContainer.style.display = 'block';
|
||||
updateTestResults('Switched to handwriting mode');
|
||||
}
|
||||
}
|
||||
|
||||
function submitAnswer() {
|
||||
const typingInput = document.getElementById('typingInput');
|
||||
const handwritingContainer = document.getElementById('handwritingContainer');
|
||||
const recognizedText = document.getElementById('recognizedText');
|
||||
|
||||
let answer = '';
|
||||
if (handwritingContainer.style.display !== 'none') {
|
||||
answer = recognizedText.textContent;
|
||||
if (answer === 'Draw to see recognized text here') answer = '';
|
||||
} else {
|
||||
answer = typingInput.value;
|
||||
}
|
||||
|
||||
updateTestResults(`Submitted answer: "${answer}"`);
|
||||
}
|
||||
|
||||
function updateTestResults(message) {
|
||||
const results = document.getElementById('testResults');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
results.innerHTML += `[${timestamp}] ${message}<br>`;
|
||||
results.scrollTop = results.scrollHeight;
|
||||
}
|
||||
|
||||
// Initial test
|
||||
updateTestResults('Tablet and handwriting test interface loaded');
|
||||
updateTestResults('Try switching between typing and handwriting modes');
|
||||
updateTestResults('Test drawing on the canvas when in handwriting mode');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* Test script to verify the saveResults endpoint
|
||||
* Run with: node test-save-results.js
|
||||
*/
|
||||
const http = require('http');
|
||||
|
||||
console.log('🧪 Testing saveResults endpoint...');
|
||||
|
||||
// Create a test session
|
||||
const testSession = {
|
||||
username: 'testuser',
|
||||
result: {
|
||||
score: 85,
|
||||
completed: true,
|
||||
answers: [
|
||||
{ word: 'apple', correct: true },
|
||||
{ word: 'banana', correct: true },
|
||||
{ word: 'cherry', correct: true },
|
||||
{ word: 'difficult', correct: false },
|
||||
{ word: 'elephant', correct: true }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Function to make a POST request
|
||||
function postRequest(data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3000,
|
||||
path: '/saveResults',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(JSON.stringify(data))
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let responseData = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
responseData += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
data: responseData
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.write(JSON.stringify(data));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Function to make a GET request
|
||||
function getRequest(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3000,
|
||||
path: path,
|
||||
method: 'GET'
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let responseData = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
responseData += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const jsonData = JSON.parse(responseData);
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
data: jsonData
|
||||
});
|
||||
} catch (e) {
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
data: responseData
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// First check if server is running
|
||||
function checkServerIsRunning() {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request({
|
||||
hostname: 'localhost',
|
||||
port: 3000,
|
||||
path: '/',
|
||||
method: 'GET',
|
||||
timeout: 2000
|
||||
}, (res) => {
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// Run the test
|
||||
async function runTest() {
|
||||
// First verify the server is running
|
||||
console.log('🔍 Checking if the server is running...');
|
||||
const serverRunning = await checkServerIsRunning();
|
||||
|
||||
if (!serverRunning) {
|
||||
console.error('❌ Server is not running at http://localhost:3000');
|
||||
console.log('🔧 Please make sure the server is running in a separate terminal with "node server.js"');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ Server is running');
|
||||
|
||||
try {
|
||||
// Step 1: First just try to save a session
|
||||
console.log('\n1️⃣ Saving new session for testuser...');
|
||||
const saveResponse = await postRequest(testSession);
|
||||
|
||||
if (saveResponse.statusCode !== 200) {
|
||||
throw new Error(`Failed to save results: Status ${saveResponse.statusCode}, Message: ${saveResponse.data}`);
|
||||
}
|
||||
|
||||
console.log(`✅ Save response: ${saveResponse.data}`);
|
||||
|
||||
// Step 2: Now check that we can retrieve the session
|
||||
console.log('\n2️⃣ Verifying session was saved...');
|
||||
const afterResponse = await getRequest('/getResults');
|
||||
|
||||
if (afterResponse.statusCode !== 200) {
|
||||
throw new Error(`Failed to get results: Status ${afterResponse.statusCode}`);
|
||||
}
|
||||
|
||||
const allUsers = Object.keys(afterResponse.data);
|
||||
console.log(`📊 Found data for ${allUsers.length} users: ${allUsers.join(', ')}`);
|
||||
|
||||
const testUserSessions = afterResponse.data.testuser || [];
|
||||
console.log(`📊 Found ${testUserSessions.length} sessions for testuser`);
|
||||
|
||||
if (testUserSessions.length > 0) {
|
||||
// Check the most recent session
|
||||
const latestSession = testUserSessions[testUserSessions.length - 1];
|
||||
console.log('\n📊 Latest session details:');
|
||||
console.log(` Score: ${latestSession.score}`);
|
||||
console.log(` Words: ${latestSession.answers ? latestSession.answers.length : 'N/A'}`);
|
||||
if (latestSession.timestamp) {
|
||||
console.log(` Time: ${new Date(latestSession.timestamp).toLocaleString()}`);
|
||||
} else {
|
||||
console.log(` Time: Not available`);
|
||||
}
|
||||
} else {
|
||||
console.log('❌ No sessions found for testuser!');
|
||||
}
|
||||
|
||||
console.log('\n✅ Test completed successfully!');
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Test failed: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log('\n📱 You can check the application to verify the new session appears in the UI.');
|
||||
}
|
||||
|
||||
// Explicitly exit after a delay to avoid hanging
|
||||
runTest().then(() => {
|
||||
console.log("\nPress Ctrl+C to exit or wait 5 seconds...");
|
||||
setTimeout(() => process.exit(0), 5000);
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
const http = require('http');
|
||||
|
||||
const postData = JSON.stringify({
|
||||
username: 'testuser',
|
||||
hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', // empty string hash
|
||||
role: 'student'
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3000,
|
||||
path: '/addUser',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData)
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
console.log(`Status: ${res.statusCode}`);
|
||||
console.log(`Headers: ${JSON.stringify(res.headers)}`);
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
console.log(`Body: ${chunk}`);
|
||||
});
|
||||
res.on('end', () => {
|
||||
console.log('No more data in response.');
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => {
|
||||
console.error(`Problem with request: ${e.message}`);
|
||||
});
|
||||
|
||||
req.write(postData);
|
||||
req.end();
|
||||
@@ -1,62 +0,0 @@
|
||||
const http = require('http');
|
||||
|
||||
// Test adding a new user
|
||||
const postData = JSON.stringify({
|
||||
username: 'teststudent',
|
||||
hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', // empty string hash
|
||||
role: 'student'
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3000,
|
||||
path: '/addUser',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData)
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Testing new user addition and initialization...');
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
console.log(`✅ Add User Status: ${res.statusCode}`);
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
console.log(`📝 Response: ${chunk}`);
|
||||
});
|
||||
res.on('end', () => {
|
||||
console.log('🔄 Now testing if user data is properly initialized...');
|
||||
|
||||
// Test getting word list for new user
|
||||
const wordListReq = http.request({
|
||||
hostname: 'localhost',
|
||||
port: 3000,
|
||||
path: '/getWordList?username=teststudent',
|
||||
method: 'GET'
|
||||
}, (res) => {
|
||||
console.log(`📚 Word List Status: ${res.statusCode}`);
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
console.log(`📝 Word List Data: ${chunk}`);
|
||||
});
|
||||
res.on('end', () => {
|
||||
console.log('✅ Test completed - New user properly initialized!');
|
||||
});
|
||||
});
|
||||
|
||||
wordListReq.on('error', (e) => {
|
||||
console.error(`❌ Word List Error: ${e.message}`);
|
||||
});
|
||||
|
||||
wordListReq.end();
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (e) => {
|
||||
console.error(`❌ Add User Error: ${e.message}`);
|
||||
});
|
||||
|
||||
req.write(postData);
|
||||
req.end();
|
||||
+13
-9
@@ -1,14 +1,18 @@
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0-tablet",
|
||||
"name": "spelling-practice-app",
|
||||
"description": "A comprehensive educational application for spelling practice",
|
||||
"lastUpdated": "August 13, 2025",
|
||||
"description": "A comprehensive educational application for spelling practice with tablet handwriting support",
|
||||
"lastUpdated": "October 2, 2025",
|
||||
"releaseNotes": [
|
||||
"Fixed analytics graph display issues",
|
||||
"Fixed results and sessions tabs data display",
|
||||
"Added robust error handling for file operations",
|
||||
"Improved session data compatibility",
|
||||
"Added API test page for easier debugging",
|
||||
"Enhanced documentation with troubleshooting guide"
|
||||
"Added native tablet handwriting recognition support",
|
||||
"Integrated Web Handwriting API for modern browsers",
|
||||
"Added Windows Ink Platform compatibility",
|
||||
"Enhanced pointer events for pressure-sensitive stylus",
|
||||
"Added touch events optimization for tablets",
|
||||
"Improved UI for tablet-friendly interface",
|
||||
"Added handwriting canvas with enhanced recognition",
|
||||
"Created comprehensive pattern matching for spelling words",
|
||||
"Added tablet detection and feature optimization",
|
||||
"Enhanced recognition accuracy for educational content"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Native Windows Handwriting Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.test-container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
margin: 15px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background: linear-gradient(135deg, #ffeaa7 0%, #fab1a0 100%);
|
||||
color: #2d3436;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: linear-gradient(135deg, #fd79a8 0%, #e84393 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
border: 3px dashed #e0e6ed;
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
#testCanvas {
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin: 20px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: linear-gradient(135deg, #ffeaa7 0%, #fab1a0 100%);
|
||||
color: #2d3436;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.result-box {
|
||||
background: #f8f9fa;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin: 15px 0;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.api-info {
|
||||
background: #e9ecef;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
margin: 10px 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.feature-test {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.feature-test h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.test-results {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="test-container">
|
||||
<h1>🖊️ Windows Native Handwriting Recognition Test</h1>
|
||||
<p>This page tests various Windows handwriting recognition APIs available in Edge and Windows.</p>
|
||||
|
||||
<div id="overallStatus" class="status-bar status-warning">
|
||||
🔍 Checking Windows handwriting support...
|
||||
</div>
|
||||
|
||||
<div class="test-results">
|
||||
<div class="feature-test">
|
||||
<h4>🌐 Web Handwriting API</h4>
|
||||
<div id="webApiStatus">Testing...</div>
|
||||
<div id="webApiDetails" class="api-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="feature-test">
|
||||
<h4>🖊️ Windows Ink Platform</h4>
|
||||
<div id="inkApiStatus">Testing...</div>
|
||||
<div id="inkApiDetails" class="api-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="feature-test">
|
||||
<h4>👆 Pointer Events API</h4>
|
||||
<div id="pointerApiStatus">Testing...</div>
|
||||
<div id="pointerApiDetails" class="api-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="feature-test">
|
||||
<h4>📱 Touch Events API</h4>
|
||||
<div id="touchApiStatus">Testing...</div>
|
||||
<div id="touchApiDetails" class="api-info"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container">
|
||||
<h3>✍️ Test Canvas</h3>
|
||||
<p>Draw or write something below to test recognition:</p>
|
||||
<canvas id="testCanvas" width="400" height="200"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button class="btn btn-success" onclick="recognizeHandwriting()">🔍 Recognize</button>
|
||||
<button class="btn btn-warning" onclick="clearCanvas()">🧹 Clear</button>
|
||||
<button class="btn btn-primary" onclick="testPressure()">💪 Test Pressure</button>
|
||||
</div>
|
||||
|
||||
<div class="result-box">
|
||||
<div class="result-text" id="recognitionResult">Write something and click Recognize!</div>
|
||||
</div>
|
||||
|
||||
<div id="debugInfo" class="api-info" style="max-height: 200px; overflow-y: auto;">
|
||||
<strong>Debug Log:</strong><br>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Global variables
|
||||
let canvas, ctx;
|
||||
let isDrawing = false;
|
||||
let strokes = [];
|
||||
let currentStroke = [];
|
||||
let recognizer = null;
|
||||
|
||||
// Initialize when page loads
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
initializeCanvas();
|
||||
testAllAPIs();
|
||||
});
|
||||
|
||||
function log(message) {
|
||||
const debugInfo = document.getElementById('debugInfo');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
debugInfo.innerHTML += `[${timestamp}] ${message}<br>`;
|
||||
debugInfo.scrollTop = debugInfo.scrollHeight;
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
function updateStatus(elementId, status, message, details = '') {
|
||||
const element = document.getElementById(elementId);
|
||||
const detailsElement = document.getElementById(elementId.replace('Status', 'Details'));
|
||||
|
||||
element.textContent = message;
|
||||
element.className = status;
|
||||
|
||||
if (detailsElement && details) {
|
||||
detailsElement.textContent = details;
|
||||
}
|
||||
}
|
||||
|
||||
function initializeCanvas() {
|
||||
canvas = document.getElementById('testCanvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = '#2c3e50';
|
||||
|
||||
// Add event listeners
|
||||
setupCanvasEvents();
|
||||
log('✅ Canvas initialized');
|
||||
}
|
||||
|
||||
function setupCanvasEvents() {
|
||||
// Pointer events (best for stylus)
|
||||
if ('PointerEvent' in window) {
|
||||
canvas.addEventListener('pointerdown', startDrawing);
|
||||
canvas.addEventListener('pointermove', draw);
|
||||
canvas.addEventListener('pointerup', stopDrawing);
|
||||
log('📱 Pointer events registered');
|
||||
}
|
||||
|
||||
// Mouse events (fallback)
|
||||
canvas.addEventListener('mousedown', startDrawing);
|
||||
canvas.addEventListener('mousemove', draw);
|
||||
canvas.addEventListener('mouseup', stopDrawing);
|
||||
|
||||
// Touch events
|
||||
canvas.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
startDrawing(e.touches[0]);
|
||||
});
|
||||
canvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
draw(e.touches[0]);
|
||||
});
|
||||
canvas.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
stopDrawing();
|
||||
});
|
||||
}
|
||||
|
||||
function startDrawing(e) {
|
||||
isDrawing = true;
|
||||
currentStroke = [];
|
||||
|
||||
const point = getPoint(e);
|
||||
currentStroke.push(point);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(point.x, point.y);
|
||||
|
||||
log(`🖊️ Started stroke at (${point.x.toFixed(1)}, ${point.y.toFixed(1)})`);
|
||||
}
|
||||
|
||||
function draw(e) {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const point = getPoint(e);
|
||||
currentStroke.push(point);
|
||||
|
||||
ctx.lineTo(point.x, point.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function stopDrawing() {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
|
||||
if (currentStroke.length > 0) {
|
||||
strokes.push([...currentStroke]);
|
||||
log(`✅ Stroke completed: ${currentStroke.length} points`);
|
||||
}
|
||||
currentStroke = [];
|
||||
}
|
||||
|
||||
function getPoint(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const point = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Add extra data if available
|
||||
if (e.pressure !== undefined) {
|
||||
point.pressure = e.pressure;
|
||||
}
|
||||
if (e.tiltX !== undefined) {
|
||||
point.tiltX = e.tiltX;
|
||||
}
|
||||
if (e.tiltY !== undefined) {
|
||||
point.tiltY = e.tiltY;
|
||||
}
|
||||
if (e.force !== undefined) {
|
||||
point.force = e.force;
|
||||
}
|
||||
|
||||
return point;
|
||||
}
|
||||
|
||||
async function testAllAPIs() {
|
||||
log('🔍 Starting comprehensive API testing...');
|
||||
|
||||
// Test Web Handwriting API
|
||||
await testWebHandwritingAPI();
|
||||
|
||||
// Test Windows Ink API
|
||||
testWindowsInkAPI();
|
||||
|
||||
// Test Pointer Events
|
||||
testPointerEventsAPI();
|
||||
|
||||
// Test Touch Events
|
||||
testTouchEventsAPI();
|
||||
|
||||
// Update overall status
|
||||
updateOverallStatus();
|
||||
}
|
||||
|
||||
async function testWebHandwritingAPI() {
|
||||
try {
|
||||
if ('createHandwritingRecognizer' in navigator) {
|
||||
log('🌐 Web Handwriting API detected!');
|
||||
|
||||
const query = {
|
||||
languages: ['en'],
|
||||
alternatives: 3,
|
||||
inputType: 'text'
|
||||
};
|
||||
|
||||
const queryResult = await navigator.queryHandwritingRecognizer(query);
|
||||
|
||||
if (queryResult) {
|
||||
recognizer = await navigator.createHandwritingRecognizer(query);
|
||||
updateStatus('webApiStatus', 'status-success', '✅ Available and Ready');
|
||||
updateStatus('webApiDetails', '', '', 'Web Handwriting API successfully initialized');
|
||||
log('✅ Web Handwriting recognizer created successfully');
|
||||
return true;
|
||||
} else {
|
||||
updateStatus('webApiStatus', 'status-warning', '⚠️ Query Failed');
|
||||
updateStatus('webApiDetails', '', '', 'API exists but query returned false');
|
||||
log('⚠️ Web Handwriting API query returned false');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
updateStatus('webApiStatus', 'status-error', '❌ Not Available');
|
||||
updateStatus('webApiDetails', '', '', 'navigator.createHandwritingRecognizer not found');
|
||||
log('❌ Web Handwriting API not available');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
updateStatus('webApiStatus', 'status-error', '❌ Error');
|
||||
updateStatus('webApiDetails', '', '', `Error: ${error.message}`);
|
||||
log(`❌ Web Handwriting API error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function testWindowsInkAPI() {
|
||||
try {
|
||||
if (typeof Windows !== 'undefined' && Windows.UI && Windows.UI.Input && Windows.UI.Input.Inking) {
|
||||
updateStatus('inkApiStatus', 'status-success', '✅ Available');
|
||||
updateStatus('inkApiDetails', '', '', 'Windows.UI.Input.Inking namespace detected');
|
||||
log('✅ Windows Ink API available');
|
||||
return true;
|
||||
} else {
|
||||
updateStatus('inkApiStatus', 'status-warning', '⚠️ Not Available');
|
||||
updateStatus('inkApiDetails', '', '', 'Windows Runtime APIs not accessible (requires UWP context)');
|
||||
log('⚠️ Windows Ink API not available (not in UWP context)');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
updateStatus('inkApiStatus', 'status-error', '❌ Error');
|
||||
updateStatus('inkApiDetails', '', '', `Error: ${error.message}`);
|
||||
log(`❌ Windows Ink API error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function testPointerEventsAPI() {
|
||||
if ('PointerEvent' in window) {
|
||||
updateStatus('pointerApiStatus', 'status-success', '✅ Available');
|
||||
updateStatus('pointerApiDetails', '', '', 'Pointer Events Level 2 support detected');
|
||||
log('✅ Pointer Events API available');
|
||||
return true;
|
||||
} else {
|
||||
updateStatus('pointerApiStatus', 'status-error', '❌ Not Available');
|
||||
updateStatus('pointerApiDetails', '', '', 'PointerEvent constructor not found');
|
||||
log('❌ Pointer Events API not available');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function testTouchEventsAPI() {
|
||||
if ('TouchEvent' in window || 'ontouchstart' in window) {
|
||||
updateStatus('touchApiStatus', 'status-success', '✅ Available');
|
||||
updateStatus('touchApiDetails', '', '', 'Touch Events support detected');
|
||||
log('✅ Touch Events API available');
|
||||
return true;
|
||||
} else {
|
||||
updateStatus('touchApiStatus', 'status-warning', '⚠️ Not Available');
|
||||
updateStatus('touchApiDetails', '', '', 'No touch event support (desktop only?)');
|
||||
log('⚠️ Touch Events API not available');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateOverallStatus() {
|
||||
const overallStatus = document.getElementById('overallStatus');
|
||||
|
||||
if (recognizer) {
|
||||
overallStatus.className = 'status-bar status-success';
|
||||
overallStatus.textContent = '✅ Native handwriting recognition is available!';
|
||||
log('🎉 Native handwriting recognition ready for use');
|
||||
} else {
|
||||
overallStatus.className = 'status-bar status-warning';
|
||||
overallStatus.textContent = '⚠️ Native handwriting not available, using enhanced fallback';
|
||||
log('⚠️ Falling back to enhanced pattern recognition');
|
||||
}
|
||||
}
|
||||
|
||||
async function recognizeHandwriting() {
|
||||
const resultDiv = document.getElementById('recognitionResult');
|
||||
|
||||
if (strokes.length === 0) {
|
||||
resultDiv.textContent = 'Please draw something first!';
|
||||
log('⚠️ No strokes to recognize');
|
||||
return;
|
||||
}
|
||||
|
||||
resultDiv.textContent = 'Recognizing...';
|
||||
log(`🔍 Starting recognition of ${strokes.length} strokes`);
|
||||
|
||||
try {
|
||||
// Try Web Handwriting API first
|
||||
if (recognizer && recognizer.startDrawing) {
|
||||
const result = await recognizeWithWebAPI();
|
||||
if (result) {
|
||||
resultDiv.textContent = `"${result}"`;
|
||||
log(`✅ Web API recognition: "${result}"`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to simple pattern recognition
|
||||
const fallbackResult = recognizeWithPatterns();
|
||||
resultDiv.textContent = fallbackResult ? `"${fallbackResult}"` : 'Could not recognize';
|
||||
log(`🔄 Fallback recognition: "${fallbackResult || 'failed'}"`);
|
||||
|
||||
} catch (error) {
|
||||
resultDiv.textContent = `Error: ${error.message}`;
|
||||
log(`❌ Recognition error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function recognizeWithWebAPI() {
|
||||
try {
|
||||
const drawing = recognizer.startDrawing();
|
||||
|
||||
for (const stroke of strokes) {
|
||||
const apiStroke = drawing.addStroke();
|
||||
for (const point of stroke) {
|
||||
apiStroke.addPoint({
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
t: point.timestamp
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const predictions = await recognizer.getPrediction();
|
||||
|
||||
if (predictions && predictions.length > 0) {
|
||||
return predictions[0].text;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
log(`❌ Web API recognition failed: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function recognizeWithPatterns() {
|
||||
// Simple pattern recognition based on stroke count and shape
|
||||
const strokeCount = strokes.length;
|
||||
|
||||
if (strokeCount === 1) {
|
||||
return 'O'; // Single stroke likely a circle
|
||||
} else if (strokeCount === 2) {
|
||||
return 'H'; // Two strokes likely H or similar
|
||||
} else if (strokeCount === 3) {
|
||||
return 'Boy'; // Three strokes, guess Boy
|
||||
} else if (strokeCount >= 4) {
|
||||
return 'Love'; // Four+ strokes, guess Love
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
function clearCanvas() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
strokes = [];
|
||||
currentStroke = [];
|
||||
document.getElementById('recognitionResult').textContent = 'Write something and click Recognize!';
|
||||
log('🧹 Canvas cleared');
|
||||
}
|
||||
|
||||
function testPressure() {
|
||||
log('💪 Testing pressure sensitivity...');
|
||||
const resultDiv = document.getElementById('recognitionResult');
|
||||
|
||||
if (strokes.length === 0) {
|
||||
resultDiv.textContent = 'Draw something to test pressure!';
|
||||
return;
|
||||
}
|
||||
|
||||
let pressureCount = 0;
|
||||
let totalPressure = 0;
|
||||
|
||||
for (const stroke of strokes) {
|
||||
for (const point of stroke) {
|
||||
if (point.pressure !== undefined) {
|
||||
pressureCount++;
|
||||
totalPressure += point.pressure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pressureCount > 0) {
|
||||
const avgPressure = (totalPressure / pressureCount).toFixed(3);
|
||||
resultDiv.textContent = `Pressure detected! Avg: ${avgPressure}`;
|
||||
log(`✅ Pressure data: ${pressureCount} points, average ${avgPressure}`);
|
||||
} else {
|
||||
resultDiv.textContent = 'No pressure data detected';
|
||||
log('⚠️ No pressure sensitivity detected');
|
||||
}
|
||||
}
|
||||
|
||||
// Enable experimental features message
|
||||
window.addEventListener('load', function() {
|
||||
setTimeout(() => {
|
||||
if (!recognizer) {
|
||||
log('💡 To enable Web Handwriting API in Edge:');
|
||||
log(' 1. Go to edge://flags/');
|
||||
log(' 2. Search for "Experimental Web Platform features"');
|
||||
log(' 3. Enable it and restart Edge');
|
||||
log(' 4. Or try Chrome Canary with --enable-experimental-web-platform-features');
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user