Compare commits
19 Commits
58def0c006
...
e541b10f3f
| Author | SHA1 | Date | |
|---|---|---|---|
| e541b10f3f | |||
| f5b0caf194 | |||
| fc309738f4 | |||
| 983d9b1597 | |||
| de1eb635d6 | |||
| 76b8cb3199 | |||
| 4822e03ab4 | |||
| 2c0cd01ab2 | |||
| 24510001b5 | |||
| e3c421a52e | |||
| 4310d9a1c4 | |||
| 20baba9a5f | |||
| 81ea4244af | |||
| 5ca10f3b0e | |||
| b84ac04396 | |||
| def4ddabb7 | |||
| d69a14d80b | |||
| 2b69a07cf6 | |||
| fe9adb97fa |
30
.github/workflows/publish.yml
vendored
@@ -3,15 +3,15 @@ name: Build and Publish Docker Images
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'release-*'
|
||||
- "v*"
|
||||
- "release-*"
|
||||
branches:
|
||||
- main
|
||||
- production
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., 1.0.0)'
|
||||
description: "Version tag (e.g., 1.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -76,8 +76,8 @@ jobs:
|
||||
- name: Build and push frontend image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./
|
||||
file: ./frontend/Dockerfile.prod
|
||||
context: ./frontend
|
||||
file: ./frontend/Dockerfile.svelte
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
@@ -199,22 +199,24 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Clean up old backend images
|
||||
uses: actions/delete-package-versions@v4
|
||||
uses: actions/delete-package-versions@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
package-name: '${{ env.PROJECT_NAME }}-backend'
|
||||
package-type: 'container'
|
||||
owner: ${{ github.repository_owner }}
|
||||
package-name: ${{ env.PROJECT_NAME }}-backend
|
||||
package-type: container
|
||||
min-versions-to-keep: 10
|
||||
delete-only-untagged-versions: false
|
||||
delete-only-untagged-versions: true
|
||||
|
||||
- name: Clean up old frontend images
|
||||
uses: actions/delete-package-versions@v4
|
||||
- name: Clean up old backend images
|
||||
uses: actions/delete-package-versions@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
package-name: '${{ env.PROJECT_NAME }}-frontend'
|
||||
package-type: 'container'
|
||||
owner: ${{ github.repository_owner }}
|
||||
package-name: ${{ env.PROJECT_NAME }}-frontend
|
||||
package-type: container
|
||||
min-versions-to-keep: 10
|
||||
delete-only-untagged-versions: false
|
||||
delete-only-untagged-versions: true
|
||||
|
||||
- name: Post deployment info
|
||||
run: |
|
||||
|
||||
5
.gitignore
vendored
@@ -98,6 +98,11 @@ Thumbs.db
|
||||
dist/
|
||||
build/
|
||||
|
||||
# SvelteKit
|
||||
.svelte-kit/
|
||||
frontend/.svelte-kit/
|
||||
frontend/build/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
# EDH Stats - Deployment Checklist
|
||||
|
||||
## Pre-Deployment Verification
|
||||
|
||||
### Code Quality
|
||||
- [x] No SQLite references remain
|
||||
- [x] All database operations use async/await
|
||||
- [x] All queries are parameterized
|
||||
- [x] Error handling implemented
|
||||
- [x] Transaction support working
|
||||
- [x] Repository pattern applied
|
||||
|
||||
### Docker & Build
|
||||
- [x] package-lock.json synchronized
|
||||
- [x] Dockerfile using correct npm syntax
|
||||
- [x] All dependencies installed
|
||||
- [x] Docker build completes without errors
|
||||
|
||||
### Configuration
|
||||
- [x] .env.example created with PostgreSQL variables
|
||||
- [x] docker-compose.yml updated for PostgreSQL
|
||||
- [x] deploy.sh updated for production
|
||||
- [x] GitHub Actions workflow updated
|
||||
|
||||
### Documentation
|
||||
- [x] POSTGRES_MIGRATION_COMPLETE.md created
|
||||
- [x] MIGRATION_STATUS.md created
|
||||
- [x] Repository pattern documented
|
||||
- [x] Deployment procedures documented
|
||||
|
||||
---
|
||||
|
||||
## Development Deployment
|
||||
|
||||
### Prerequisites
|
||||
- Docker installed
|
||||
- Docker Compose installed
|
||||
- Git repository cloned
|
||||
|
||||
### Deployment Steps
|
||||
|
||||
```bash
|
||||
# 1. Navigate to project directory
|
||||
cd /path/to/edh-stats
|
||||
|
||||
# 2. Start all services
|
||||
docker-compose up
|
||||
|
||||
# 3. Wait for services to start
|
||||
# Expected output:
|
||||
# - postgres: "database system is ready to accept connections"
|
||||
# - db-migrate: "Migrations completed successfully!"
|
||||
# - backend: "Server listening on http://0.0.0.0:3000"
|
||||
# - frontend: "nginx running"
|
||||
|
||||
# 4. Verify services are running
|
||||
docker-compose ps
|
||||
|
||||
# 5. Test API endpoint
|
||||
curl http://localhost:3002/api/health
|
||||
```
|
||||
|
||||
### Verification
|
||||
- [ ] PostgreSQL is running
|
||||
- [ ] Migrations completed successfully
|
||||
- [ ] Backend API is responsive
|
||||
- [ ] Frontend is accessible at http://localhost:8081
|
||||
- [ ] Database has test data
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run migrations manually
|
||||
docker-compose exec backend node src/database/migrate.js migrate
|
||||
|
||||
# Seed sample data
|
||||
docker-compose exec backend node src/database/migrate.js seed
|
||||
|
||||
# Query database
|
||||
docker-compose exec postgres psql -U edh_user -d edh_stats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose installed
|
||||
- GitHub Container Registry access
|
||||
- GHCR token with write:packages permission
|
||||
- `.env` file with production secrets
|
||||
|
||||
### Build & Push Images
|
||||
|
||||
```bash
|
||||
# 1. Navigate to project directory
|
||||
cd /path/to/edh-stats
|
||||
|
||||
# 2. Set GitHub user (if not already set)
|
||||
export GITHUB_USER=your-github-username
|
||||
|
||||
# 3. Build and push images
|
||||
./deploy.sh 1.0.0 <GHCR_TOKEN>
|
||||
|
||||
# Expected output:
|
||||
# - Version file updated
|
||||
# - Backend image built and pushed
|
||||
# - Frontend image built and pushed
|
||||
# - Deployment config generated
|
||||
```
|
||||
|
||||
### Create Environment File
|
||||
|
||||
```bash
|
||||
# 1. Create .env file
|
||||
cat > .env << 'ENVEOF'
|
||||
# PostgreSQL Database
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=edh_stats
|
||||
DB_USER=edh_user
|
||||
DB_PASSWORD=$(openssl rand -base64 32)
|
||||
|
||||
# Application
|
||||
NODE_ENV=production
|
||||
LOG_LEVEL=warn
|
||||
|
||||
# Security
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN=https://yourdomain.com
|
||||
|
||||
# Registration
|
||||
ALLOW_REGISTRATION=false
|
||||
ENVEOF
|
||||
|
||||
# 2. Review .env file
|
||||
cat .env
|
||||
|
||||
# 3. Make sure passwords are secure
|
||||
# The script generates random passwords above, but review them!
|
||||
```
|
||||
|
||||
### Deploy Services
|
||||
|
||||
```bash
|
||||
# 1. Pull latest images
|
||||
docker pull ghcr.io/your-username/edh-stats-backend:1.0.0
|
||||
docker pull ghcr.io/your-username/edh-stats-frontend:1.0.0
|
||||
|
||||
# 2. Start services
|
||||
docker-compose -f docker-compose.prod.deployed.yml up -d
|
||||
|
||||
# 3. Monitor migrations
|
||||
docker-compose logs -f db-migrate
|
||||
|
||||
# 4. Wait for migrations to complete
|
||||
# Expected output:
|
||||
# db-migrate: "Migrations completed successfully!"
|
||||
|
||||
# 5. Check all services are running
|
||||
docker-compose ps
|
||||
|
||||
# 6. Verify services are healthy
|
||||
docker-compose exec backend curl http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
### Post-Deployment Verification
|
||||
|
||||
- [ ] PostgreSQL is running and healthy
|
||||
- [ ] Database migrations completed successfully
|
||||
- [ ] Backend API is responding to health checks
|
||||
- [ ] Frontend is accessible via reverse proxy
|
||||
- [ ] SSL/TLS certificate is valid (if behind proxy)
|
||||
- [ ] Application logs show no errors
|
||||
- [ ] Database has expected schema
|
||||
|
||||
### Testing Production Deployment
|
||||
|
||||
```bash
|
||||
# Test API health
|
||||
curl https://yourdomain.com/api/health
|
||||
|
||||
# Check version
|
||||
curl https://yourdomain.com/api/auth/config
|
||||
|
||||
# Monitor logs
|
||||
docker-compose logs -f backend
|
||||
|
||||
# Database check
|
||||
docker-compose exec postgres pg_isready -U edh_user
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback Procedure (if needed)
|
||||
|
||||
### If Build Fails
|
||||
```bash
|
||||
# Review logs
|
||||
docker-compose logs backend
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
|
||||
# Check Dockerfile changes
|
||||
git diff HEAD~1 backend/Dockerfile
|
||||
|
||||
# Revert if necessary
|
||||
git revert HEAD
|
||||
```
|
||||
|
||||
### If Migration Fails
|
||||
```bash
|
||||
# Check migration logs
|
||||
docker-compose logs db-migrate
|
||||
|
||||
# Review migration SQL
|
||||
cat backend/src/database/migrations.sql
|
||||
|
||||
# Restart migration container
|
||||
docker-compose restart db-migrate
|
||||
|
||||
# Monitor migration progress
|
||||
docker-compose logs -f db-migrate
|
||||
```
|
||||
|
||||
### If Database Issues
|
||||
```bash
|
||||
# Stop all services
|
||||
docker-compose down
|
||||
|
||||
# Remove database volume (WARNING: deletes data!)
|
||||
docker volume rm edh-stats_postgres_data
|
||||
|
||||
# Restart services
|
||||
docker-compose -f docker-compose.prod.deployed.yml up -d
|
||||
|
||||
# Migrations will run automatically on fresh start
|
||||
```
|
||||
|
||||
### Complete Rollback to Previous Version
|
||||
|
||||
```bash
|
||||
# Checkout previous commit
|
||||
git checkout HEAD~5
|
||||
|
||||
# Rebuild everything
|
||||
docker-compose down -v
|
||||
docker-compose build --no-cache
|
||||
|
||||
# Start fresh
|
||||
docker-compose up -d
|
||||
|
||||
# Monitor startup
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Maintenance
|
||||
|
||||
### Regular Checks
|
||||
```bash
|
||||
# Daily health check
|
||||
docker-compose exec backend curl http://localhost:3000/api/health
|
||||
|
||||
# Weekly database backup
|
||||
docker exec edh-stats-postgres pg_dump -U edh_user -d edh_stats > backup-$(date +%Y%m%d).sql
|
||||
|
||||
# Monitor container resources
|
||||
docker stats
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| "Connection refused" | Check PostgreSQL is running: `docker-compose ps` |
|
||||
| "Migrations failed" | Review logs: `docker-compose logs db-migrate` |
|
||||
| "Database is locked" | Stop and restart container: `docker-compose restart postgres` |
|
||||
| "Out of memory" | Increase Docker memory limit or reduce connection pool |
|
||||
| "Port already in use" | Change port in docker-compose.yml or stop conflicting service |
|
||||
|
||||
### Performance Monitoring
|
||||
|
||||
```bash
|
||||
# Connection pool status
|
||||
docker-compose exec postgres psql -U edh_user -d edh_stats -c "SELECT datname, count(*) FROM pg_stat_activity GROUP BY datname;"
|
||||
|
||||
# Slow queries (if enabled)
|
||||
docker-compose exec postgres psql -U edh_user -d edh_stats -c "SELECT * FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 10;"
|
||||
|
||||
# Database size
|
||||
docker-compose exec postgres psql -U edh_user -d edh_stats -c "SELECT pg_size_pretty(pg_database_size('edh_stats'));"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
| Task | Status | Date |
|
||||
|------|--------|------|
|
||||
| Code Review | ✅ Complete | |
|
||||
| Docker Build Test | ✅ Complete | |
|
||||
| Documentation Review | ✅ Complete | |
|
||||
| Development Deployment | ⬜ Pending | |
|
||||
| Production Deployment | ⬜ Pending | |
|
||||
| Health Verification | ⬜ Pending | |
|
||||
| Performance Testing | ⬜ Pending | |
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check MIGRATION_STATUS.md
|
||||
2. Review POSTGRES_MIGRATION_COMPLETE.md
|
||||
3. Check docker-compose logs
|
||||
4. Review Dockerfile changes
|
||||
5. Verify environment configuration
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 17, 2026
|
||||
**Version**: PostgreSQL Migration Complete
|
||||
**Status**: Ready for Deployment
|
||||
319
DOCKER_SETUP.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# Docker Setup Guide
|
||||
|
||||
This project uses two different Docker Compose configurations for development and production environments.
|
||||
|
||||
## 🛠️ Development Setup (docker-compose.yml)
|
||||
|
||||
**Purpose**: Local development with hot reload and live code updates
|
||||
|
||||
### Features
|
||||
- ✅ **Hot Reload**: Code changes instantly reflected without rebuilding
|
||||
- ✅ **Volume Mounts**: Source code mounted for real-time updates
|
||||
- ✅ **Vite Dev Server**: Fast HMR (Hot Module Replacement)
|
||||
- ✅ **Development Dependencies**: Includes all dev tools
|
||||
|
||||
### Starting Development Environment
|
||||
|
||||
```bash
|
||||
# Start all services (Postgres, Backend, Frontend)
|
||||
docker compose up
|
||||
|
||||
# Start in detached mode
|
||||
docker compose up -d
|
||||
|
||||
# Rebuild containers after dependency changes
|
||||
docker compose up --build
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Stop services
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Access Points
|
||||
- **Frontend Dev Server**: http://localhost:5173 (Vite with hot reload)
|
||||
- **Backend API**: http://localhost:3002
|
||||
- **PostgreSQL**: localhost:5432
|
||||
|
||||
### Important: CORS Configuration
|
||||
The backend's `CORS_ORIGIN` is set to `http://localhost:5173` in development to match the Vite dev server port. If you change the frontend port, you must also update the `CORS_ORIGIN` environment variable in `docker-compose.yml`.
|
||||
|
||||
### How It Works
|
||||
|
||||
The frontend service:
|
||||
- Uses `Dockerfile.dev` which runs `npm run dev`
|
||||
- Mounts source directories as volumes
|
||||
- Runs Vite dev server on port 5173
|
||||
- Code changes trigger instant updates in the browser
|
||||
|
||||
### Volume Mounts
|
||||
```yaml
|
||||
volumes:
|
||||
- ./frontend/src:/app/src # Svelte components
|
||||
- ./frontend/static:/app/static # Static assets
|
||||
- ./frontend/svelte.config.js:/app/svelte.config.js
|
||||
- ./frontend/vite.config.js:/app/vite.config.js
|
||||
- ./frontend/tailwind.config.js:/app/tailwind.config.js
|
||||
- ./frontend/postcss.config.js:/app/postcss.config.js
|
||||
- /app/node_modules # Exclude from mount
|
||||
- /app/.svelte-kit # Exclude from mount
|
||||
```
|
||||
|
||||
### Common Development Commands
|
||||
|
||||
```bash
|
||||
# Restart just the frontend after config changes
|
||||
docker compose restart frontend
|
||||
|
||||
# Rebuild frontend after package.json changes
|
||||
docker compose up --build frontend
|
||||
|
||||
# View frontend logs
|
||||
docker compose logs -f frontend
|
||||
|
||||
# Shell into frontend container
|
||||
docker compose exec frontend sh
|
||||
|
||||
# Run npm commands in container
|
||||
docker compose exec frontend npm run build
|
||||
docker compose exec frontend npm install <package>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Production Setup (docker-compose.prod.deployed.yml)
|
||||
|
||||
**Purpose**: Production deployment with optimized builds
|
||||
|
||||
### Features
|
||||
- ✅ **Production Build**: Optimized SvelteKit static build
|
||||
- ✅ **Nginx Server**: Serves pre-built static files
|
||||
- ✅ **No Volume Mounts**: Everything baked into the image
|
||||
- ✅ **Resource Limits**: Memory and CPU constraints
|
||||
- ✅ **Traefik Integration**: Automatic HTTPS with Let's Encrypt
|
||||
|
||||
### Generation
|
||||
|
||||
The production compose file is **automatically generated** by:
|
||||
|
||||
1. **GitHub Actions** (`.github/workflows/publish.yml`)
|
||||
- Runs on every push to `main` branch
|
||||
- Builds Docker images
|
||||
- Publishes to GitHub Container Registry
|
||||
- Generates `docker-compose.prod.deployed.yml`
|
||||
- Attaches to GitHub Release
|
||||
|
||||
2. **Local Script** (`deploy.sh`)
|
||||
```bash
|
||||
./deploy.sh --build-local
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
```bash
|
||||
# Deploy to production
|
||||
docker compose -f docker-compose.prod.deployed.yml up -d
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.prod.deployed.yml logs -f
|
||||
|
||||
# Stop production
|
||||
docker compose -f docker-compose.prod.deployed.yml down
|
||||
|
||||
# Update to new version
|
||||
docker compose -f docker-compose.prod.deployed.yml pull
|
||||
docker compose -f docker-compose.prod.deployed.yml up -d
|
||||
```
|
||||
|
||||
### Access Points
|
||||
- **Frontend**: https://edh.zlor.fi (via Traefik)
|
||||
- **Backend API**: http://localhost:3002
|
||||
|
||||
### How It Works
|
||||
|
||||
The frontend service:
|
||||
- Uses `Dockerfile.svelte` (production multi-stage build)
|
||||
- Runs `npm run build` to create optimized static files
|
||||
- Serves via Nginx on port 80
|
||||
- Includes cache headers and compression
|
||||
- No source code in the container
|
||||
|
||||
---
|
||||
|
||||
## 📊 Comparison
|
||||
|
||||
| Feature | Development | Production |
|
||||
|---------|-------------|------------|
|
||||
| **Compose File** | `docker-compose.yml` | `docker-compose.prod.deployed.yml` |
|
||||
| **Frontend Dockerfile** | `Dockerfile.dev` | `Dockerfile.svelte` |
|
||||
| **Frontend Server** | Vite Dev Server | Nginx |
|
||||
| **Frontend Port** | 5173 | 80 |
|
||||
| **Hot Reload** | ✅ Yes | ❌ No |
|
||||
| **Volume Mounts** | ✅ Yes | ❌ No |
|
||||
| **Build Time** | Fast (no build) | Slower (full build) |
|
||||
| **File Size** | Large | Small |
|
||||
| **Resource Usage** | Higher | Lower |
|
||||
| **Cache Busting** | Not needed | Automatic (Vite hashes) |
|
||||
| **HTTPS/Traefik** | ❌ No | ✅ Yes |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration Files
|
||||
|
||||
### Development
|
||||
- `docker-compose.yml` - Main development compose file
|
||||
- `frontend/Dockerfile.dev` - Dev container with Vite
|
||||
- `frontend/vite.config.js` - Dev server config with API proxy
|
||||
|
||||
### Production
|
||||
- `docker-compose.prod.deployed.yml` - **Generated**, do not edit
|
||||
- `frontend/Dockerfile.svelte` - Production multi-stage build
|
||||
- `frontend/nginx.conf` - Nginx configuration for SPA
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Development Issues
|
||||
|
||||
**Problem**: Changes not reflecting
|
||||
**Solution**: Check that volumes are mounted correctly
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
**Problem**: Port 5173 already in use
|
||||
**Solution**: Change port in `docker-compose.yml`
|
||||
```yaml
|
||||
ports:
|
||||
- '5174:5173' # Use different external port
|
||||
```
|
||||
|
||||
**Problem**: Frontend can't connect to backend / CORS errors
|
||||
**Solution**: Ensure CORS_ORIGIN matches the frontend URL
|
||||
```bash
|
||||
# Check backend CORS configuration
|
||||
docker compose logs backend | grep CORS
|
||||
|
||||
# Frontend is on port 5173, so backend needs:
|
||||
# CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# Restart backend after changing CORS_ORIGIN
|
||||
docker compose restart backend
|
||||
```
|
||||
|
||||
### Production Issues
|
||||
|
||||
**Problem**: Old code being served
|
||||
**Solution**: Pull new images and restart
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.deployed.yml pull
|
||||
docker compose -f docker-compose.prod.deployed.yml up -d
|
||||
```
|
||||
|
||||
**Problem**: 404 on page refresh
|
||||
**Solution**: Check nginx.conf has SPA fallback
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Best Practices
|
||||
|
||||
### Development Workflow
|
||||
|
||||
1. **Start Docker services once**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. **Code normally**: Changes auto-reload in browser
|
||||
|
||||
3. **After package.json changes**:
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up --build frontend
|
||||
```
|
||||
|
||||
4. **Before committing**: Test production build
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
|
||||
1. **Let GitHub Actions build**: Push to `main` branch
|
||||
2. **Download compose file**: From GitHub Release
|
||||
3. **Deploy on server**:
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.deployed.yml pull
|
||||
docker compose -f docker-compose.prod.deployed.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Environment Variables
|
||||
|
||||
### Development (.env or docker-compose.yml)
|
||||
```bash
|
||||
# Database
|
||||
DB_NAME=edh_stats
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=edh_password
|
||||
|
||||
# Backend
|
||||
JWT_SECRET=dev-jwt-secret-change-in-production
|
||||
CORS_ORIGIN=http://localhost
|
||||
ALLOW_REGISTRATION=true
|
||||
|
||||
# Frontend
|
||||
VITE_API_URL=http://localhost:3002
|
||||
```
|
||||
|
||||
### Production (.env on server)
|
||||
```bash
|
||||
# Database
|
||||
DB_NAME=edh_stats
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=$(openssl rand -base64 32)
|
||||
|
||||
# Backend
|
||||
JWT_SECRET=$(openssl rand -base64 32)
|
||||
CORS_ORIGIN=https://yourdomain.com
|
||||
ALLOW_REGISTRATION=false
|
||||
LOG_LEVEL=warn
|
||||
|
||||
# No VITE_ vars needed - API proxy in nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Reference
|
||||
|
||||
```bash
|
||||
# Development
|
||||
docker compose up # Start dev environment
|
||||
docker compose logs -f frontend # Watch frontend logs
|
||||
docker compose restart frontend # Restart after config change
|
||||
docker compose down # Stop everything
|
||||
|
||||
# Production
|
||||
docker compose -f docker-compose.prod.deployed.yml pull
|
||||
docker compose -f docker-compose.prod.deployed.yml up -d
|
||||
docker compose -f docker-compose.prod.deployed.yml logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [SvelteKit Documentation](https://kit.svelte.dev/docs)
|
||||
- [Vite Configuration](https://vitejs.dev/config/)
|
||||
- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/)
|
||||
- [Nginx SPA Configuration](https://www.nginx.com/blog/deploying-nginx-nginx-plus-docker/)
|
||||
49
README.md
@@ -1,6 +1,6 @@
|
||||
# EDH/Commander Stats Tracker
|
||||
|
||||
A lightweight, responsive web application for tracking Magic: The Gathering EDH/Commander games with comprehensive statistics and analytics. Built with Fastify (Node.js), PostgreSQL, and Alpine.js for optimal performance and scalability.
|
||||
A lightweight, responsive web application for tracking Magic: The Gathering EDH/Commander games with comprehensive statistics and analytics. Built with Fastify (Node.js), PostgreSQL, and SvelteKit for optimal performance and scalability.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -9,7 +9,7 @@ A lightweight, responsive web application for tracking Magic: The Gathering EDH/
|
||||
#### Authentication & Users
|
||||
- **Secure Authentication**: JWT-based login/registration system with password hashing (HS512).
|
||||
- **User Profile Management**: View and edit user profile information.
|
||||
- **Session Management**: Persistent authentication with localStorage/sessionStorage support.
|
||||
- **Session Management**: Secure, HttpOnly cookie-based authentication for seamless sessions.
|
||||
- **Configurable Registration**: Toggle user registration on/off via `ALLOW_REGISTRATION` environment variable for controlled access.
|
||||
|
||||
#### Commander Management
|
||||
@@ -64,7 +64,7 @@ A lightweight, responsive web application for tracking Magic: The Gathering EDH/
|
||||
#### User Interface
|
||||
- **Responsive Design**: Mobile-friendly layout using Tailwind CSS.
|
||||
- **Dark Theme**: Professional dark color scheme with proper contrast.
|
||||
- **Alpine.js Components**: Lightweight, reactive UI without heavy frameworks.
|
||||
- **Svelte Components**: Reactive UI powered by SvelteKit and shared stores.
|
||||
- **Professional Navigation**: Easy access to all major features.
|
||||
- **Accessibility**: Semantic HTML and accessible form controls.
|
||||
|
||||
@@ -103,7 +103,7 @@ A lightweight, responsive web application for tracking Magic: The Gathering EDH/
|
||||
|
||||
- **Backend**: Fastify (Node.js v20+)
|
||||
- **Database**: PostgreSQL 16 with connection pooling (pg library)
|
||||
- **Frontend**: Alpine.js, Tailwind CSS (CDN)
|
||||
- **Frontend**: SvelteKit, Tailwind CSS
|
||||
- **Visualization**: Chart.js
|
||||
- **Containerization**: Docker & Docker Compose
|
||||
- **Authentication**: JWT with HS512 hashing
|
||||
@@ -242,15 +242,15 @@ edh-stats/
|
||||
│ ├── package.json # Node.js dependencies
|
||||
│ └── Dockerfile
|
||||
├── frontend/
|
||||
│ ├── public/
|
||||
│ │ ├── css/ # Tailwind styles
|
||||
│ │ ├── js/ # Alpine.js components
|
||||
│ │ ├── components/ # Reusable HTML components
|
||||
│ │ ├── *.html # View files
|
||||
│ │ └── round-counter.html # Live round counter (NEW)
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # SvelteKit pages and layouts
|
||||
│ │ ├── lib/components/ # Shared UI components (NavBar, Footer, etc.)
|
||||
│ │ └── lib/stores/ # Svelte stores (auth, derived state)
|
||||
│ ├── static/ # Static assets (fonts, images, css)
|
||||
│ ├── tailwind.config.js # Tailwind configuration
|
||||
│ ├── package.json # Node.js dependencies
|
||||
│ └── Dockerfile
|
||||
│ ├── vite.config.js # Vite dev/proxy configuration
|
||||
│ ├── package.json # Frontend dependencies
|
||||
│ └── Dockerfile* # Dev/prod Dockerfiles
|
||||
├── postgres_data/ # Persisted PostgreSQL data (Docker volume)
|
||||
├── docs/ # Documentation
|
||||
├── FIXES.md # Detailed list of fixes applied
|
||||
@@ -384,24 +384,19 @@ docker compose exec postgres psql -U postgres -d edh_stats
|
||||
The application logs connection pool info at startup. To debug connection issues, set `LOG_LEVEL=debug` to see detailed connection logging.
|
||||
|
||||
### Frontend State Management
|
||||
- Alpine.js components handle all state management
|
||||
- No external state management library needed
|
||||
- Components:
|
||||
- `app()`: Main dashboard and page initialization
|
||||
- `commanderManager()`: Commander CRUD operations
|
||||
- `gameManager()`: Game logging and editing
|
||||
- `roundCounterApp()`: Real-time round counter with game timing
|
||||
- Authentication tokens stored in `localStorage` (persistent) or `sessionStorage` (session-only)
|
||||
- Data persistence: `localStorage` for round counter state
|
||||
- Dynamic content loading: Partial HTML pages loaded and inserted via loaders
|
||||
- SvelteKit components manage UI state through Svelte's built-in reactivity.
|
||||
- Shared state (authentication, derived data) lives in `src/lib/stores`, primarily `auth.js`.
|
||||
- Authentication relies on secure HttpOnly cookies; the store only tracks user metadata and loading state.
|
||||
- Persistent client data (e.g., round counter progress) is stored in `localStorage` with defensive guards for SSR.
|
||||
- Routing, forms, and API interactions are handled with first-class SvelteKit primitives instead of manual DOM loaders.
|
||||
|
||||
### Authentication Flow
|
||||
1. User registers with username and password
|
||||
2. Password hashed with bcryptjs (12 rounds)
|
||||
3. JWT token generated (HS512 algorithm)
|
||||
4. Token stored in browser (localStorage/sessionStorage)
|
||||
5. Token validated on protected routes
|
||||
6. Automatic token validation on component initialization
|
||||
4. Secure session cookie issued to the browser
|
||||
5. Protected routes validate the JWT extracted from the cookie
|
||||
6. `auth.init()` validates the cookie on app start and hydrates the user store
|
||||
|
||||
### Error Handling
|
||||
- All API errors return appropriate HTTP status codes
|
||||
@@ -448,13 +443,13 @@ The application logs connection pool info at startup. To debug connection issues
|
||||
- **Registration Control**: Added `ALLOW_REGISTRATION` environment variable to toggle signup availability
|
||||
- **Game API Response**: Ensured all game endpoints return complete commander information (name, colors)
|
||||
- **Form Validation**: Improved notes field handling to prevent null value validation errors
|
||||
- **Frontend Error Handling**: Fixed Alpine.js key binding issues in top commanders template
|
||||
- **Frontend Error Handling**: Fixed legacy Alpine.js key binding issues in top commanders template prior to the Svelte migration
|
||||
|
||||
### Previous Session Fixes
|
||||
This version includes **19+ bug fixes and improvements** addressing:
|
||||
- SQL parameter mismatches and injection vulnerabilities
|
||||
- Boolean type conversion issues in form submissions
|
||||
- Invalid Alpine.js expressions and duplicate elements
|
||||
- Invalid legacy Alpine.js expressions and duplicate elements
|
||||
- Corrupted SVG paths in UI components
|
||||
- Field name mismatches between frontend and backend
|
||||
- Color parsing and null/undefined value handling
|
||||
|
||||
22
TODO.md
@@ -1,22 +0,0 @@
|
||||
# ToDos/Bugs to check and adjust
|
||||
|
||||
## frontend/public/games.html
|
||||
|
||||
[x] export logs to JSON via a button in the top right corner, the file should have the current date in the name i.e: edh_games_16_01_2026.json
|
||||
|
||||
## frontend/public/round-counter.html
|
||||
|
||||
[x] check if every function in round-counter.js is being used
|
||||
[x] instead of Stop Game -> Pause game and do not reset the time counter
|
||||
[x] End Game & Log Results -> reset the counter
|
||||
[x] Rest button -> has issues to display on mobile
|
||||
|
||||
## frontend/public/login.html
|
||||
|
||||
[x] check the cookie for remembering the login credentials, how many seconds does it work?
|
||||
[x] prolong the cookie to stay loged in, when using the Remember me checkbox
|
||||
|
||||
## backend/routes/commanders.js
|
||||
|
||||
[x] unable to delete commanders
|
||||
[x] unable to update commanders
|
||||
25
backend/package-lock.json
generated
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "edh-stats-backend",
|
||||
"version": "1.1.0",
|
||||
"version": "2.3.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "edh-stats-backend",
|
||||
"version": "1.1.0",
|
||||
"version": "2.3.6",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
@@ -132,6 +133,26 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/cookie": {
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
|
||||
"integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.0",
|
||||
"fastify-plugin": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "edh-stats-backend",
|
||||
"version": "2.1.8",
|
||||
"version": "2.3.6",
|
||||
"description": "Backend API for EDH/Commander stats tracking application",
|
||||
"main": "src/server.js",
|
||||
"type": "module",
|
||||
@@ -15,14 +15,15 @@
|
||||
"db:seed": "node src/database/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"pg": "^8.11.3",
|
||||
"close-with-grace": "^1.2.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"fastify": "^5.7.1",
|
||||
"pg": "^8.11.3",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
@@ -17,12 +17,16 @@ CREATE TABLE IF NOT EXISTS commanders (
|
||||
name TEXT NOT NULL CHECK(LENGTH(name) >= 2),
|
||||
colors JSONB NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
archived BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT valid_colors CHECK(jsonb_typeof(colors) = 'array')
|
||||
);
|
||||
|
||||
ALTER TABLE commanders
|
||||
ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Games table with all requested statistics
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
@@ -10,15 +10,15 @@ export class CommanderRepository extends Repository {
|
||||
/**
|
||||
* Create a new commander
|
||||
*/
|
||||
async createCommander(userId, name, colors) {
|
||||
async createCommander(userId, name, colors, archived = false) {
|
||||
try {
|
||||
const result = await dbManager.query(
|
||||
`
|
||||
INSERT INTO ${this.tableName} (name, colors, user_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, colors, user_id, created_at, updated_at
|
||||
INSERT INTO ${this.tableName} (name, colors, user_id, archived)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, colors, user_id, archived, created_at, updated_at
|
||||
`,
|
||||
[name, colors, userId]
|
||||
[name, colors, userId, archived]
|
||||
)
|
||||
|
||||
return result.rows[0]
|
||||
@@ -63,6 +63,7 @@ export class CommanderRepository extends Repository {
|
||||
c.name,
|
||||
c.colors,
|
||||
c.user_id,
|
||||
c.archived,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
|
||||
@@ -102,6 +103,7 @@ export class CommanderRepository extends Repository {
|
||||
c.name,
|
||||
c.colors,
|
||||
c.user_id,
|
||||
c.archived,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
|
||||
@@ -133,6 +135,7 @@ export class CommanderRepository extends Repository {
|
||||
c.name,
|
||||
c.colors,
|
||||
c.user_id,
|
||||
c.archived,
|
||||
c.created_at,
|
||||
c.updated_at,
|
||||
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
|
||||
@@ -201,6 +204,12 @@ export class CommanderRepository extends Repository {
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (updateData.archived !== undefined) {
|
||||
updates.push(`archived = $${paramCount}`)
|
||||
values.push(updateData.archived)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('No valid fields to update')
|
||||
}
|
||||
@@ -233,6 +242,22 @@ export class CommanderRepository extends Repository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count commanders for a user filtered by name query
|
||||
*/
|
||||
async countCommandersByUserIdAndQuery(userId, query) {
|
||||
try {
|
||||
const searchQuery = `%${query}%`
|
||||
const result = await dbManager.query(
|
||||
`SELECT COUNT(*) as count FROM ${this.tableName} WHERE user_id = $1 AND name ILIKE $2`,
|
||||
[userId, searchQuery]
|
||||
)
|
||||
return parseInt(result.rows[0].count, 10) || 0
|
||||
} catch (error) {
|
||||
throw new Error('Failed to count commanders by query')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find commander by name and user
|
||||
*/
|
||||
|
||||
@@ -109,6 +109,38 @@ const updateUsernameSchema = z.object({
|
||||
})
|
||||
})
|
||||
|
||||
const AUTH_COOKIE_NAME = 'edh_stats_token'
|
||||
const ONE_WEEK_IN_SECONDS = 60 * 60 * 24 * 7
|
||||
const secureCookies =
|
||||
process.env.COOKIE_SECURE === 'true' || process.env.NODE_ENV === 'production'
|
||||
|
||||
function buildCookieOptions(maxAgeSeconds) {
|
||||
const base = {
|
||||
path: '/',
|
||||
sameSite: 'strict',
|
||||
httpOnly: true,
|
||||
secure: secureCookies
|
||||
}
|
||||
|
||||
if (maxAgeSeconds) {
|
||||
return {
|
||||
...base,
|
||||
maxAge: maxAgeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
function setAuthCookie(reply, token, remember) {
|
||||
const maxAge = remember ? ONE_WEEK_IN_SECONDS : undefined
|
||||
reply.setCookie(AUTH_COOKIE_NAME, token, buildCookieOptions(maxAge))
|
||||
}
|
||||
|
||||
function clearAuthCookie(reply) {
|
||||
reply.clearCookie(AUTH_COOKIE_NAME, buildCookieOptions(0))
|
||||
}
|
||||
|
||||
export default async function authRoutes(fastify, options) {
|
||||
// Initialize repository
|
||||
const userRepo = new UserRepository()
|
||||
@@ -204,6 +236,8 @@ export default async function authRoutes(fastify, options) {
|
||||
}
|
||||
)
|
||||
|
||||
setAuthCookie(reply, token, false)
|
||||
|
||||
reply.code(201).send({
|
||||
message: 'User registered successfully',
|
||||
user: {
|
||||
@@ -246,7 +280,7 @@ export default async function authRoutes(fastify, options) {
|
||||
async (request, reply) => {
|
||||
try {
|
||||
// LAYER 1: Schema validation
|
||||
const { username, password } = loginSchema.parse(request.body)
|
||||
const { username, password, remember } = loginSchema.parse(request.body)
|
||||
|
||||
// LAYER 2: Find user (also serves as authorization check)
|
||||
const user = await userRepo.findByUsername(username)
|
||||
@@ -278,10 +312,12 @@ export default async function authRoutes(fastify, options) {
|
||||
username: user.username
|
||||
},
|
||||
{
|
||||
expiresIn: request.body.remember ? '7d' : '2h'
|
||||
expiresIn: remember ? '7d' : '2h'
|
||||
}
|
||||
)
|
||||
|
||||
setAuthCookie(reply, token, remember)
|
||||
|
||||
reply.send({
|
||||
message: 'Login successful',
|
||||
user: {
|
||||
@@ -341,6 +377,8 @@ export default async function authRoutes(fastify, options) {
|
||||
}
|
||||
)
|
||||
|
||||
setAuthCookie(reply, token, false)
|
||||
|
||||
reply.send({
|
||||
message: 'Token refreshed successfully',
|
||||
token
|
||||
@@ -400,6 +438,30 @@ export default async function authRoutes(fastify, options) {
|
||||
}
|
||||
)
|
||||
|
||||
fastify.get('/session', async (request, reply) => {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
const user = await userRepo.findById(request.user.id)
|
||||
|
||||
if (!user) {
|
||||
clearAuthCookie(reply)
|
||||
return reply.send({ authenticated: false })
|
||||
}
|
||||
|
||||
reply.send({
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
createdAt: user.created_at
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
reply.send({ authenticated: false })
|
||||
}
|
||||
})
|
||||
|
||||
// Update user profile
|
||||
fastify.patch(
|
||||
'/me',
|
||||
@@ -566,8 +628,8 @@ export default async function authRoutes(fastify, options) {
|
||||
request.body
|
||||
)
|
||||
|
||||
// Verify current password
|
||||
const user = await userRepo.findByUsername(request.user.username)
|
||||
// Verify current password - use id (stable across username changes)
|
||||
const user = await userRepo.findById(request.user.id)
|
||||
if (!user) {
|
||||
reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
@@ -647,8 +709,8 @@ export default async function authRoutes(fastify, options) {
|
||||
request.body
|
||||
)
|
||||
|
||||
// Verify current password
|
||||
const user = await userRepo.findByUsername(request.user.username)
|
||||
// Verify current password - use id (stable across username changes)
|
||||
const user = await userRepo.findById(request.user.id)
|
||||
if (!user) {
|
||||
reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
@@ -737,6 +799,8 @@ export default async function authRoutes(fastify, options) {
|
||||
return
|
||||
}
|
||||
|
||||
clearAuthCookie(reply)
|
||||
|
||||
reply.send({
|
||||
message: 'Account deleted successfully'
|
||||
})
|
||||
@@ -749,4 +813,9 @@ export default async function authRoutes(fastify, options) {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
fastify.post('/logout', async (request, reply) => {
|
||||
clearAuthCookie(reply)
|
||||
reply.send({ message: 'Logged out' })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@ const createCommanderSchema = z.object({
|
||||
errorMap: () => ({ message: 'Colors must be an array' })
|
||||
}
|
||||
)
|
||||
.min(1, 'Select at least one color')
|
||||
.max(5, 'Maximum 5 colors allowed')
|
||||
.refine((colors) => hasNoDuplicateColors(colors), {
|
||||
message: 'Duplicate colors are not allowed'
|
||||
})
|
||||
}),
|
||||
archived: z.boolean('Archived must be a boolean').optional().default(false)
|
||||
})
|
||||
|
||||
const updateCommanderSchema = z.object({
|
||||
@@ -50,12 +50,12 @@ const updateCommanderSchema = z.object({
|
||||
errorMap: () => ({ message: 'Invalid color (must be W, U, B, R, or G)' })
|
||||
})
|
||||
)
|
||||
.min(1, 'Select at least one color')
|
||||
.max(5, 'Maximum 5 colors allowed')
|
||||
.refine((colors) => hasNoDuplicateColors(colors), {
|
||||
message: 'Duplicate colors are not allowed'
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
archived: z.boolean('Archived must be a boolean').optional()
|
||||
})
|
||||
|
||||
const commanderQuerySchema = z.object({
|
||||
@@ -104,6 +104,7 @@ function transformCommander(cmd) {
|
||||
name: cmd.name,
|
||||
colors: cmd.colors || [],
|
||||
userId: cmd.user_id,
|
||||
archived: cmd.archived ?? false,
|
||||
totalGames: parseInt(cmd.total_games) || 0,
|
||||
totalWins: parseInt(cmd.total_wins) || 0,
|
||||
winRate: cmd.win_rate ? parseFloat(cmd.win_rate) : 0,
|
||||
@@ -147,14 +148,19 @@ export default async function commanderRoutes(fastify, options) {
|
||||
commanders = await commanderRepo.getCommandersByUserId(userId, limit, offset, sortBy, sortOrder)
|
||||
}
|
||||
|
||||
reply.send({
|
||||
commanders: commanders.map(transformCommander),
|
||||
pagination: {
|
||||
total: commanders.length,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
})
|
||||
const totalCount = q
|
||||
? await commanderRepo.countCommandersByUserIdAndQuery(userId, q)
|
||||
: await commanderRepo.countCommandersByUserId(userId)
|
||||
|
||||
reply.send({
|
||||
commanders: commanders.map(transformCommander),
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + commanders.length < totalCount
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.code(400).send({
|
||||
@@ -215,12 +221,13 @@ export default async function commanderRoutes(fastify, options) {
|
||||
return
|
||||
}
|
||||
|
||||
reply.send({
|
||||
commander: {
|
||||
...commander,
|
||||
colors: commander.colors || []
|
||||
}
|
||||
})
|
||||
reply.send({
|
||||
commander: {
|
||||
...commander,
|
||||
colors: commander.colors || [],
|
||||
archived: commander.archived ?? false
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
fastify.log.error('Get commander error:', error)
|
||||
reply.code(500).send({
|
||||
@@ -286,7 +293,8 @@ export default async function commanderRoutes(fastify, options) {
|
||||
const commander = await commanderRepo.createCommander(
|
||||
userId,
|
||||
validatedData.name,
|
||||
colorsJson
|
||||
colorsJson,
|
||||
validatedData.archived ?? false
|
||||
)
|
||||
|
||||
reply.code(201).send({
|
||||
@@ -350,7 +358,7 @@ export default async function commanderRoutes(fastify, options) {
|
||||
|
||||
// Convert colors array to JSON if provided
|
||||
const updatePayload = { ...updateData }
|
||||
if (updatePayload.colors) {
|
||||
if (updatePayload.colors !== undefined) {
|
||||
updatePayload.colors = JSON.stringify(updatePayload.colors)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,10 +50,21 @@ export default async function statsRoutes(fastify, options) {
|
||||
[userId]
|
||||
)
|
||||
|
||||
const inactiveCommandersResult = await dbManager.get(
|
||||
`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM commanders
|
||||
WHERE user_id = $1 AND archived = TRUE
|
||||
`,
|
||||
[userId]
|
||||
)
|
||||
|
||||
reply.send({
|
||||
totalGames: stats?.total_games || 0,
|
||||
winRate: stats?.win_rate || 0,
|
||||
totalCommanders: stats?.total_commanders || 0,
|
||||
inactiveCommanders:
|
||||
parseInt(inactiveCommandersResult?.count, 10) || 0,
|
||||
avgRounds: Math.round(stats?.avg_rounds || 0)
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dotenv/config.js'
|
||||
import fastify from 'fastify'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
import cors from '@fastify/cors'
|
||||
import cookie from '@fastify/cookie'
|
||||
import jwt from '@fastify/jwt'
|
||||
import closeWithGrace from 'close-with-grace'
|
||||
|
||||
@@ -28,6 +29,19 @@ export default async function build(opts = {}) {
|
||||
// Register plugins
|
||||
await app.register(cors, corsConfig)
|
||||
|
||||
const shouldUseSecureCookies =
|
||||
process.env.COOKIE_SECURE === 'true' || process.env.NODE_ENV === 'production'
|
||||
|
||||
await app.register(cookie, {
|
||||
hook: 'onRequest',
|
||||
parseOptions: {
|
||||
sameSite: 'strict',
|
||||
httpOnly: true,
|
||||
secure: shouldUseSecureCookies,
|
||||
path: '/'
|
||||
}
|
||||
})
|
||||
|
||||
// Add request logging hook
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
request.startTime = Date.now()
|
||||
@@ -59,6 +73,16 @@ export default async function build(opts = {}) {
|
||||
secret: jwtConfig.secret
|
||||
})
|
||||
|
||||
app.addHook('preHandler', async (request, reply) => {
|
||||
if (
|
||||
!request.headers.authorization &&
|
||||
request.cookies &&
|
||||
request.cookies.edh_stats_token
|
||||
) {
|
||||
request.headers.authorization = `Bearer ${request.cookies.edh_stats_token}`
|
||||
}
|
||||
})
|
||||
|
||||
// Register global rate limiting if configured
|
||||
await app.register(rateLimit, {
|
||||
global: true,
|
||||
|
||||
28
deploy.sh
@@ -133,7 +133,7 @@ check_github_token() {
|
||||
update_version_file() {
|
||||
print_header "Updating Version File"
|
||||
|
||||
local version_file="./frontend/public/version.txt"
|
||||
local version_file="./frontend/static/version.txt"
|
||||
local current_version=""
|
||||
|
||||
# Check if version file exists
|
||||
@@ -172,21 +172,21 @@ build_backend() {
|
||||
}
|
||||
|
||||
build_frontend() {
|
||||
print_header "Building Frontend Image"
|
||||
print_header "Building Frontend Image (SvelteKit)"
|
||||
|
||||
print_info "Building: ${FRONTEND_IMAGE}"
|
||||
print_info "Building for architectures: linux/amd64"
|
||||
|
||||
# Note: Dockerfile.prod is now a permanent file in the repository
|
||||
# It uses a multi-stage build to compile Tailwind CSS in production
|
||||
# SvelteKit multi-stage build with Vite bundler
|
||||
# Automatically handles cache busting with hashed filenames
|
||||
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--file ./frontend/Dockerfile.prod \
|
||||
--file ./frontend/Dockerfile.svelte \
|
||||
--tag "${FRONTEND_IMAGE}" \
|
||||
--tag "${FRONTEND_IMAGE_LATEST}" \
|
||||
--push \
|
||||
.
|
||||
./frontend
|
||||
|
||||
print_success "Frontend image built and pushed successfully"
|
||||
}
|
||||
@@ -402,10 +402,6 @@ networks:
|
||||
traefik-network:
|
||||
external: true
|
||||
name: traefik-network
|
||||
|
||||
x-dockge:
|
||||
urls:
|
||||
- https://edh.zlor.fi
|
||||
EOF
|
||||
|
||||
print_success "Deployment configuration generated: ${config_file}"
|
||||
@@ -442,12 +438,12 @@ print_summary() {
|
||||
echo "Version: ${VERSION}"
|
||||
echo ""
|
||||
echo "Version Management:"
|
||||
echo " Frontend version file updated: ./frontend/public/version.txt"
|
||||
echo " Version displayed in footer: v${VERSION#v}"
|
||||
echo " Frontend version file updated: ./frontend/static/version.txt"
|
||||
echo " SvelteKit with automatic cache busting (hashed filenames)"
|
||||
echo ""
|
||||
echo "Next Steps:"
|
||||
echo " 1. Commit version update:"
|
||||
echo " git add frontend/public/version.txt"
|
||||
echo " git add frontend/static/version.txt"
|
||||
echo " git commit -m \"Bump version to ${VERSION#v}\""
|
||||
echo " 2. Pull images: docker pull ${BACKEND_IMAGE}"
|
||||
echo " 3. Create .env file with PostgreSQL credentials:"
|
||||
@@ -480,6 +476,9 @@ main() {
|
||||
# Check token
|
||||
check_github_token
|
||||
|
||||
# Authenticate (must happen before build to allow --push)
|
||||
login_to_registry
|
||||
|
||||
# Update version file
|
||||
update_version_file
|
||||
|
||||
@@ -490,9 +489,6 @@ main() {
|
||||
# Verify images
|
||||
verify_images
|
||||
|
||||
# Authenticate
|
||||
login_to_registry
|
||||
|
||||
# Push images
|
||||
push_backend
|
||||
push_frontend
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# Docker Compose configuration for EDH Stats Tracker
|
||||
# DEVELOPMENT ENVIRONMENT with hot reload
|
||||
# For production deployment, use docker-compose.prod.deployed.yml (generated by deploy.sh or GitHub Actions)
|
||||
services:
|
||||
# PostgreSQL database service
|
||||
postgres:
|
||||
@@ -67,7 +69,7 @@ services:
|
||||
- DB_USER=${DB_USER:-postgres}
|
||||
- DB_PASSWORD=${DB_PASSWORD:-edh_password}
|
||||
- JWT_SECRET=${JWT_SECRET:-dev-jwt-secret-key-change-in-production}
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost}
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:5173}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
|
||||
- MAX_USERS=${MAX_USERS:-}
|
||||
@@ -91,26 +93,32 @@ services:
|
||||
networks:
|
||||
- edh-stats-network
|
||||
|
||||
# Frontend web server
|
||||
# Frontend web server (SvelteKit) - Development Mode with Hot Reload
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: edh-stats-frontend
|
||||
ports:
|
||||
- '8081:80'
|
||||
- '5173:5173' # Vite dev server
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- DOCKER=true
|
||||
- VITE_API_URL=http://localhost:3002
|
||||
volumes:
|
||||
- ./frontend/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./frontend/public:/usr/share/nginx/html:ro
|
||||
- ./frontend/src:/app/src
|
||||
- ./frontend/static:/app/static
|
||||
- ./frontend/svelte.config.js:/app/svelte.config.js
|
||||
- ./frontend/vite.config.js:/app/vite.config.js
|
||||
- ./frontend/tailwind.config.js:/app/tailwind.config.js
|
||||
- ./frontend/postcss.config.js:/app/postcss.config.js
|
||||
# Exclude node_modules and build artifacts from volume mount
|
||||
- /app/node_modules
|
||||
- /app/.svelte-kit
|
||||
command: npm run dev -- --host 0.0.0.0
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- curl
|
||||
- http://localhost:80/health
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- edh-stats-network
|
||||
|
||||
|
||||
19
frontend/Dockerfile.dev
Normal file
@@ -0,0 +1,19 @@
|
||||
# Development Dockerfile for SvelteKit with hot reload
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies (including dev dependencies)
|
||||
RUN npm install
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Expose Vite dev server port
|
||||
EXPOSE 5173
|
||||
|
||||
# Start development server with host binding for Docker
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY ./frontend/nginx.prod.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy frontend files (includes version.txt for frontend version display)
|
||||
COPY ./frontend/public /usr/share/nginx/html
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 80 443
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/health.html || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
37
frontend/Dockerfile.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
# Multi-stage build for SvelteKit frontend
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy built files from builder
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
|
||||
# Create health check file
|
||||
RUN echo "OK" > /usr/share/nginx/html/health
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/health || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -74,12 +74,7 @@ http {
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Explicitly handle HTML files - don't fallback to index.html
|
||||
location ~* \.html$ {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Handle all routes with SPA fallback
|
||||
# SPA routing - all non-asset routes fallback to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
@@ -77,12 +77,12 @@ http {
|
||||
# Hashed JS files (cache-busted)
|
||||
location ~* ^/js/.*\.(js|mjs)$ {
|
||||
limit_req zone=static burst=50 nodelay;
|
||||
expires 1y;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Regular static files (non-hashed)
|
||||
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|txt)$ {
|
||||
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|woff|woff2)$ {
|
||||
limit_req zone=static burst=50 nodelay;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
@@ -91,8 +91,9 @@ http {
|
||||
# Version file endpoint
|
||||
location = /version.txt {
|
||||
access_log off;
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# Explicitly handle HTML files - don't fallback to index.html
|
||||
@@ -117,12 +118,12 @@ http {
|
||||
# Error pages
|
||||
error_page 404 /404.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
|
||||
|
||||
location = /404.html {
|
||||
root /usr/share/nginx/html;
|
||||
internal;
|
||||
}
|
||||
|
||||
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
internal;
|
||||
|
||||
1174
frontend/package-lock.json
generated
@@ -1,29 +1,35 @@
|
||||
{
|
||||
"name": "edh-stats-frontend",
|
||||
"version": "2.1.8",
|
||||
"version": "2.3.6",
|
||||
"description": "Frontend for EDH/Commander stats tracking application",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "echo 'Static files - use nginx server' && python3 -m http.server 8080",
|
||||
"build-css": "tailwindcss -i ./css/input.css -o ./css/styles.css --watch",
|
||||
"build-css:prod": "tailwindcss -i ./css/input.css -o ./css/styles.css --minify"
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"alpinejs": "^3.13.3",
|
||||
"chart.js": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.57.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32"
|
||||
"postcss": "^8.4.32",
|
||||
"svelte": "^5.55.2",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^8.0.8"
|
||||
},
|
||||
"keywords": [
|
||||
"alpinejs",
|
||||
"sveltekit",
|
||||
"tailwindcss",
|
||||
"magic-the-gathering",
|
||||
"edh",
|
||||
"commander"
|
||||
],
|
||||
"author": "EDH Stats App",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"main": "postcss.config.js"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Page Not Found - EDH Stats Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="The page you're looking for doesn't exist. Return to EDH Stats Tracker."
|
||||
/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="min-h-full flex flex-col">
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-white shadow">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<a
|
||||
href="/dashboard.html"
|
||||
class="text-2xl font-bold font-mtg text-edh-primary"
|
||||
>
|
||||
EDH Stats
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div
|
||||
class="flex-1 flex items-center justify-center px-4 sm:px-6 lg:px-8 py-12"
|
||||
>
|
||||
<div class="max-w-md w-full text-center">
|
||||
<!-- 404 Icon -->
|
||||
<div class="mb-8">
|
||||
<div class="text-6xl font-bold text-edh-primary mb-2">404</div>
|
||||
<h1 class="text-4xl font-bold font-mtg text-gray-900 mb-4">
|
||||
Page Not Found
|
||||
</h1>
|
||||
<p class="text-lg text-gray-600 mb-8">
|
||||
Sorry, the page you're looking for doesn't exist. It might have been
|
||||
moved or deleted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="mb-8">
|
||||
<svg
|
||||
class="w-24 h-24 mx-auto text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="space-y-4">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-block px-6 py-3 bg-edh-accent text-white font-medium rounded-lg hover:bg-edh-primary transition-colors"
|
||||
>
|
||||
Return to Home
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Helpful Info -->
|
||||
<div class="mt-12 pt-8 border-t border-gray-200">
|
||||
<p class="text-xs text-gray-500">Error Code: 404 | Page Not Found</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,544 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Commanders - EDH Stats Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Manage your Magic: The Gathering EDH/Commander decks"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="h-full" x-data="commanderManager()">
|
||||
<!-- Navigation Header -->
|
||||
<header class="bg-slate-900 text-white shadow-lg">
|
||||
<nav class="container mx-auto px-4 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold font-mtg">My Commanders</h1>
|
||||
<div class="flex space-x-4">
|
||||
<a
|
||||
href="/dashboard.html"
|
||||
class="hover:text-edh-accent transition-colors"
|
||||
>
|
||||
← Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- Add Commander Section -->
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-semibold">Add New Commander</h2>
|
||||
<button @click="showAddForm = !showAddForm" class="btn btn-secondary">
|
||||
<span x-show="!showAddForm">+ Add Commander</span>
|
||||
<span x-show="showAddForm">Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Commander Form -->
|
||||
<div x-show="showAddForm" x-transition class="card">
|
||||
<form @submit.prevent="handleAddCommander">
|
||||
<h3 class="text-lg font-semibold mb-4">Commander Details</h3>
|
||||
|
||||
<!-- Commander Name -->
|
||||
<div class="mb-4">
|
||||
<label for="name" class="form-label">Commander Name</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
x-model="newCommander.name"
|
||||
@input="validateCommanderName()"
|
||||
:class="errors.name ? 'border-red-500 focus:ring-red-500' : ''"
|
||||
class="form-input"
|
||||
placeholder="Enter commander name"
|
||||
/>
|
||||
<p
|
||||
x-show="errors.name"
|
||||
x-text="errors.name"
|
||||
class="form-error"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Color Identity -->
|
||||
<div class="mb-6">
|
||||
<label class="form-label">Color Identity</label>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<!-- MTG Color Selection -->
|
||||
<div class="grid grid-cols-5 gap-2 mb-4">
|
||||
<template x-for="color in mtgColors" :key="color.id">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleNewColor(color.id)"
|
||||
:class="getButtonClass(isNewColorSelected(color.id))"
|
||||
:title="color.name"
|
||||
class="w-12 h-12 rounded-lg transition-all duration-200 relative"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-lg opacity-80"
|
||||
:style="`background-color: ${color.hex}`"
|
||||
></div>
|
||||
<span
|
||||
x-show="isNewColorSelected(color.id)"
|
||||
class="relative z-10 text-white font-bold text-shadow"
|
||||
>✓</span
|
||||
>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Selected Colors Display -->
|
||||
<div
|
||||
class="flex items-center space-x-2 p-3 bg-gray-100 rounded-lg"
|
||||
>
|
||||
<span class="text-sm text-gray-600">Selected:</span>
|
||||
<div class="flex space-x-1">
|
||||
<template
|
||||
x-for="colorId in newCommander.colors"
|
||||
:key="colorId"
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 rounded"
|
||||
:class="'color-' + colorId.toLowerCase()"
|
||||
:title="getColorName(colorId)"
|
||||
></div>
|
||||
</template>
|
||||
<span
|
||||
x-show="newCommander.colors.length === 0"
|
||||
class="text-gray-400 text-sm"
|
||||
>No colors selected</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-end space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="resetAddForm"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<span x-show="!submitting">Add Commander</span>
|
||||
<span
|
||||
x-show="submitting"
|
||||
class="loading-spinner w-5 h-5 mr-2 inline-block align-middle"
|
||||
></span>
|
||||
<span x-show="submitting">Adding...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
x-show="serverError"
|
||||
x-transition
|
||||
class="rounded-md bg-red-50 p-4 mt-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-red-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-red-800" x-text="serverError"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Section -->
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-semibold">My Commanders</h2>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
x-model="searchQuery"
|
||||
@input="debounceSearch()"
|
||||
placeholder="Search commanders..."
|
||||
class="form-input pl-10 pr-4"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="togglePopular()"
|
||||
:class="showPopular ? 'btn btn-primary' : 'btn btn-secondary'"
|
||||
>
|
||||
<span x-show="!showPopular">Show Popular</span>
|
||||
<span x-show="showPopular">Show All</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commanders Grid -->
|
||||
<div x-show="loading" class="flex justify-center py-8">
|
||||
<div class="loading-spinner w-8 h-8"></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="!loading"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
>
|
||||
<template x-for="commander in commanders" :key="commander.id">
|
||||
<div class="card hover:shadow-lg transition-shadow">
|
||||
<!-- Commander Header -->
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-900"
|
||||
x-text="commander.name"
|
||||
></h3>
|
||||
<div class="flex space-x-1 mt-1">
|
||||
<template x-for="color in commander.colors" :key="color">
|
||||
<div
|
||||
class="w-6 h-6 rounded"
|
||||
:class="'color-' + color.toLowerCase()"
|
||||
:title="getColorName(color)"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="editCommander(commander)"
|
||||
class="text-edh-accent hover:text-edh-primary transition-colors"
|
||||
title="Edit commander"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 21C12 20.4477 12.4477 20 13 20H21C21.5523 20 22 20.4477 22 21C22 21.5523 21.5523 22 21 22H13C12.4477 22 12 21.5523 12 21Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M20.7736 8.09994C22.3834 6.48381 22.315 4.36152 21.113 3.06183C20.5268 2.4281 19.6926 2.0233 18.7477 2.00098C17.7993 1.97858 16.8167 2.34127 15.91 3.09985C15.8868 3.11925 15.8645 3.13969 15.8432 3.16111L2.87446 16.1816C2.31443 16.7438 2 17.5051 2 18.2987V19.9922C2 21.0937 2.89197 22 4.00383 22H5.68265C6.48037 22 7.24524 21.6823 7.80819 21.1171L20.7736 8.09994ZM17.2071 5.79295C16.8166 5.40243 16.1834 5.40243 15.7929 5.79295C15.4024 6.18348 15.4024 6.81664 15.7929 7.20717L16.7929 8.20717C17.1834 8.59769 17.8166 8.59769 18.2071 8.20717C18.5976 7.81664 18.5976 7.18348 18.2071 6.79295L17.2071 5.79295Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="deleteCommander(commander)"
|
||||
class="text-red-600 hover:text-red-800 transition-colors"
|
||||
title="Delete commander"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 6H20M16 6L15.7294 5.18807C15.4671 4.40125 15.3359 4.00784 15.0927 3.71698C14.8779 3.46013 14.6021 3.26132 14.2905 3.13878C13.9376 3 13.523 3 12.6936 3H11.3064C10.477 3 10.0624 3 9.70951 3.13878C9.39792 3.26132 9.12208 3.46013 8.90729 3.71698C8.66405 4.00784 8.53292 4.40125 8.27064 5.18807L8 6M18 6V16.2C18 17.8802 18 18.7202 17.673 19.362C17.3854 19.9265 16.9265 20.3854 16.362 20.673C15.7202 21 14.8802 21 13.2 21H10.8C9.11984 21 8.27976 21 7.63803 20.673C7.07354 20.3854 6.6146 19.9265 6.32698 19.362C6 18.7202 6 17.8802 6 16.2V6M14 10V17M10 10V17"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commander Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 mt-4">
|
||||
<div class="text-center">
|
||||
<p
|
||||
class="text-2xl font-bold text-edh-primary"
|
||||
x-text="commander.totalGames || 0"
|
||||
></p>
|
||||
<p class="text-sm text-gray-600">Games Played</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p
|
||||
class="text-2xl font-bold"
|
||||
x-text="Math.round(commander.winRate || 0) + '%'"
|
||||
></p>
|
||||
<p class="text-sm text-gray-600">Win Rate</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p
|
||||
class="text-2xl font-bold"
|
||||
x-text="Math.round(commander.avgRounds || 0)"
|
||||
></p>
|
||||
<p class="text-sm text-gray-600">Avg Rounds</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-500">Added</p>
|
||||
<p
|
||||
class="text-sm text-gray-400"
|
||||
x-text="formatDate(commander.createdAt)"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- No Commanders Message -->
|
||||
<div
|
||||
x-show="!loading && commanders.length === 0"
|
||||
class="col-span-full text-center py-12"
|
||||
>
|
||||
<div class="card max-w-md mx-auto">
|
||||
<svg
|
||||
class="w-16 h-16 mx-auto text-gray-800 mb-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M21.8382 11.1263L21.609 13.5616C21.2313 17.5742 21.0425 19.5805 19.8599 20.7902C18.6773 22 16.9048 22 13.3599 22H10.6401C7.09517 22 5.32271 22 4.14009 20.7902C2.95748 19.5805 2.76865 17.5742 2.391 13.5616L2.16181 11.1263C1.9818 9.2137 1.8918 8.25739 2.21899 7.86207C2.39598 7.64823 2.63666 7.5172 2.89399 7.4946C3.36968 7.45282 3.96708 8.1329 5.16187 9.49307C5.77977 10.1965 6.08872 10.5482 6.43337 10.6027C6.62434 10.6328 6.81892 10.6018 6.99526 10.5131C7.31351 10.3529 7.5257 9.91812 7.95007 9.04852L10.1869 4.46486C10.9888 2.82162 11.3898 2 12 2C12.6102 2 13.0112 2.82162 13.8131 4.46485L16.0499 9.04851C16.4743 9.91812 16.6865 10.3529 17.0047 10.5131C17.1811 10.6018 17.3757 10.6328 17.5666 10.6027C17.9113 10.5482 18.2202 10.1965 18.8381 9.49307C20.0329 8.1329 20.6303 7.45282 21.106 7.4946C21.3633 7.5172 21.604 7.64823 21.781 7.86207C22.1082 8.25739 22.0182 9.2137 21.8382 11.1263ZM12.9524 12.699L12.8541 12.5227C12.4741 11.841 12.2841 11.5002 12 11.5002C11.7159 11.5002 11.5259 11.841 11.1459 12.5227L11.0476 12.699C10.9397 12.8927 10.8857 12.9896 10.8015 13.0535C10.7173 13.1174 10.6125 13.1411 10.4028 13.1886L10.2119 13.2318C9.47396 13.3987 9.10501 13.4822 9.01723 13.7645C8.92945 14.0468 9.18097 14.3409 9.68403 14.9291L9.81418 15.0813C9.95713 15.2485 10.0286 15.3321 10.0608 15.4355C10.0929 15.5389 10.0821 15.6504 10.0605 15.8734L10.0408 16.0765C9.96476 16.8613 9.92674 17.2538 10.1565 17.4282C10.3864 17.6027 10.7318 17.4436 11.4227 17.1255L11.6014 17.0432C11.7978 16.9528 11.8959 16.9076 12 16.9076C12.1041 16.9076 12.2022 16.9528 12.3986 17.0432L12.5773 17.1255C13.2682 17.4436 13.6136 17.6027 13.8435 17.4282C14.0733 17.2538 14.0352 16.8613 13.9592 16.0765L13.9395 15.8734C13.9179 15.6504 13.9071 15.5389 13.9392 15.4355C13.9714 15.3321 14.0429 15.2485 14.1858 15.0813L14.316 14.9291C14.819 14.3409 15.0706 14.0468 14.9828 13.7645C14.895 13.4822 14.526 13.3987 13.7881 13.2318L13.5972 13.1886C13.3875 13.1411 13.2827 13.1174 13.1985 13.0535C13.1143 12.9896 13.0603 12.8927 12.9524 12.699Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||
No Commanders Yet
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
You haven't added any commanders yet. Start by adding your first
|
||||
commander to begin tracking your EDH games!
|
||||
</p>
|
||||
<button @click="showAddForm = true" class="btn btn-primary w-full">
|
||||
Add Your First Commander
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<div
|
||||
x-show="editingCommander"
|
||||
x-transition
|
||||
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50"
|
||||
>
|
||||
<template x-if="editingCommander">
|
||||
<div class="card max-w-md w-full mx-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">Edit Commander</h3>
|
||||
<button
|
||||
@click="cancelEdit()"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18.571 5.429a1 1 0 0-.658-.353-1.154-.828-1.154l-4.428-4.429a1 1 0 0 .417-.23.217.474-1.474L6 18z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleUpdateCommander">
|
||||
<!-- Commander Name -->
|
||||
<div class="mb-4">
|
||||
<label for="edit-name" class="form-label">Commander Name</label>
|
||||
<input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
x-model="editingCommander.name"
|
||||
@input="validateEditCommanderName()"
|
||||
:class="editErrors.name ? 'border-red-500 focus:ring-red-500' : ''"
|
||||
class="form-input"
|
||||
/>
|
||||
<p
|
||||
x-show="editErrors.name"
|
||||
x-text="editErrors.name"
|
||||
class="form-error"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Color Identity -->
|
||||
<div class="mb-6">
|
||||
<label class="form-label">Color Identity</label>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<!-- MTG Color Selection -->
|
||||
<div class="grid grid-cols-5 gap-2 mb-4">
|
||||
<template x-for="color in mtgColors" :key="color.id">
|
||||
<button
|
||||
type="button"
|
||||
@click="toggleEditColor(color.id)"
|
||||
:class="getButtonClass(isEditColorSelected(color.id))"
|
||||
:title="color.name"
|
||||
class="w-12 h-12 rounded-lg transition-all duration-200 relative"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-lg opacity-80"
|
||||
:style="`background-color: ${color.hex}`"
|
||||
></div>
|
||||
<span
|
||||
x-show="isEditColorSelected(color.id)"
|
||||
class="relative z-10 text-white font-bold text-shadow"
|
||||
>✓</span
|
||||
>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Selected Colors Display -->
|
||||
<div
|
||||
class="flex items-center space-x-2 p-3 bg-gray-100 rounded-lg"
|
||||
>
|
||||
<span class="text-sm text-gray-600">Selected:</span>
|
||||
<div class="flex space-x-1">
|
||||
<template
|
||||
x-for="colorId in editingCommander.colors"
|
||||
:key="colorId"
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 rounded"
|
||||
:class="'color-' + colorId.toLowerCase()"
|
||||
:title="getColorName(colorId)"
|
||||
></div>
|
||||
</template>
|
||||
<span
|
||||
x-show="!editingCommander.colors || editingCommander.colors.length === 0"
|
||||
class="text-gray-400 text-sm"
|
||||
>No colors selected</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-end space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="cancelEdit()"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="editSubmitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<span x-show="!editSubmitting">Update Commander</span>
|
||||
<span
|
||||
x-show="editSubmitting"
|
||||
class="loading-spinner w-5 h-5 mr-2 inline-block align-middle"
|
||||
></span>
|
||||
<span x-show="editSubmitting">Updating...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
x-show="serverError"
|
||||
x-transition
|
||||
class="rounded-md bg-red-50 p-4 mt-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-red-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-red-800" x-text="serverError"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script src="/js/auth-guard.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
<script src="/js/commanders.js"></script>
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,333 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EDH Stats Tracker - Dashboard</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Track your Magic: The Gathering EDH/Commander games and statistics"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="h-full" x-data="app()">
|
||||
<!-- Navigation Header -->
|
||||
<header class="bg-slate-900 text-white shadow-lg">
|
||||
<nav class="container mx-auto px-4 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold font-mtg">EDH Stats</h1>
|
||||
<div class="hidden md:flex space-x-6">
|
||||
<a
|
||||
href="/commanders.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>Commanders</a
|
||||
>
|
||||
<a
|
||||
href="/round-counter.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>Round timer</a
|
||||
>
|
||||
<a
|
||||
href="/games.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>Game Log</a
|
||||
>
|
||||
<a
|
||||
href="/stats.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>Statistics</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Round Counter -->
|
||||
<div
|
||||
x-show="roundCounter.active"
|
||||
class="flex items-center space-x-2 bg-slate-800 px-3 py-2 rounded-lg"
|
||||
>
|
||||
<span class="text-sm">Round</span>
|
||||
<span
|
||||
class="text-xl font-bold"
|
||||
x-text="roundCounter.current"
|
||||
></span>
|
||||
<button
|
||||
@click="incrementRound()"
|
||||
class="ml-2 hover:text-edh-accent"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
@click="resetRoundCounter()"
|
||||
class="hover:text-edh-accent"
|
||||
title="Reset"
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="relative" x-data="{ userMenuOpen: false }">
|
||||
<button
|
||||
@click="userMenuOpen = !userMenuOpen"
|
||||
class="flex items-center space-x-2 hover:text-edh-accent"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
></path>
|
||||
</svg>
|
||||
<span x-text="currentUser?.username"></span>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="userMenuOpen"
|
||||
@click.away="userMenuOpen = false"
|
||||
x-transition
|
||||
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50"
|
||||
>
|
||||
<a
|
||||
href="/profile.html"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>Profile</a
|
||||
>
|
||||
<hr class="my-1" />
|
||||
<button
|
||||
@click="logout()"
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button @click="mobileMenuOpen = !mobileMenuOpen" class="md:hidden">
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div
|
||||
x-show="mobileMenuOpen"
|
||||
x-transition
|
||||
class="md:hidden mt-4 pt-4 border-t border-edh-secondary"
|
||||
>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<a
|
||||
href="/commanders.html"
|
||||
class="text-white hover:text-edh-accent transition-colors py-2"
|
||||
>Commanders</a
|
||||
>
|
||||
<a
|
||||
href="/round-counter.html"
|
||||
class="text-white hover:text-edh-accent transition-colors py-2"
|
||||
>Round timer</a
|
||||
>
|
||||
<a
|
||||
href="/games.html"
|
||||
class="text-white hover:text-edh-accent transition-colors py-2"
|
||||
>Game Log</a
|
||||
>
|
||||
<a
|
||||
href="/stats.html"
|
||||
class="text-white hover:text-edh-accent transition-colors py-2"
|
||||
>Statistics</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- Quick Stats (loaded via stats-cards-loader.js) -->
|
||||
<div id="stats-cards-container"></div>
|
||||
|
||||
<!-- Recent Games -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div class="card">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold">Recent Games</h2>
|
||||
<a href="/games.html" class="text-edh-accent hover:text-edh-primary"
|
||||
>Log Game →</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div x-show="loading" class="flex justify-center py-8">
|
||||
<div class="loading-spinner w-8 h-8"></div>
|
||||
</div>
|
||||
|
||||
<div x-show="!loading" class="space-y-4">
|
||||
<template x-for="game in recentGames" :key="game.id">
|
||||
<div
|
||||
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-center">
|
||||
<p
|
||||
class="text-sm text-gray-500"
|
||||
x-text="formatDate(game.date)"
|
||||
></p>
|
||||
<p
|
||||
class="text-xs text-gray-400"
|
||||
x-text="game.playerCount + ' players'"
|
||||
></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium" x-text="game.commanderName"></p>
|
||||
<div class="flex space-x-1">
|
||||
<template x-for="color in game.commanderColors || []">
|
||||
<div
|
||||
class="w-4 h-4 rounded"
|
||||
:class="'color-' + color.toLowerCase()"
|
||||
:title="getColorName(color)"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<p
|
||||
class="text-sm font-medium"
|
||||
:class="game.won ? 'text-green-600' : 'text-red-600'"
|
||||
>
|
||||
<span x-text="game.won ? 'Won' : 'Lost'"></span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
<span x-text="game.rounds + ' rounds'"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
x-show="recentGames.length === 0"
|
||||
class="text-center py-8 text-gray-500"
|
||||
>
|
||||
<p>No games logged yet.</p>
|
||||
<a
|
||||
href="/games.html"
|
||||
class="text-edh-accent hover:text-edh-primary"
|
||||
>Log your first game</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Commanders -->
|
||||
<div class="card">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold">Top Commanders</h2>
|
||||
<a
|
||||
href="/commanders.html"
|
||||
class="text-edh-accent hover:text-edh-primary"
|
||||
>Manage →</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div x-show="loading" class="flex justify-center py-8">
|
||||
<div class="loading-spinner w-8 h-8"></div>
|
||||
</div>
|
||||
|
||||
<div x-show="!loading" class="space-y-4">
|
||||
<template
|
||||
x-for="commander in topCommanders"
|
||||
:key="commander.commanderId"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex space-x-1">
|
||||
<template x-for="color in commander.colors" :key="color">
|
||||
<div
|
||||
class="w-6 h-6 rounded"
|
||||
:class="'color-' + color.toLowerCase()"
|
||||
:title="getColorName(color)"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium" x-text="commander.name"></p>
|
||||
<p
|
||||
class="text-sm text-gray-500"
|
||||
x-text="commander.totalGames + ' games'"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<p
|
||||
class="font-bold text-edh-primary"
|
||||
x-text="(commander.winRate !== undefined && commander.winRate !== null ? commander.winRate : 0) + '%'"
|
||||
></p>
|
||||
<p class="text-xs text-gray-500">
|
||||
<span
|
||||
x-text="(commander.avgRounds !== undefined && commander.avgRounds !== null && !isNaN(commander.avgRounds) ? Math.round(commander.avgRounds) : 0) + ' avg rounds'"
|
||||
></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
x-show="topCommanders.length === 0"
|
||||
class="text-center py-8 text-gray-500"
|
||||
>
|
||||
<p>No stats yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script src="/js/auth-guard.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
<script src="/js/stats-cards-loader.js"></script>
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,11 +0,0 @@
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-800 text-white py-8 mt-12">
|
||||
<div class="container mx-auto px-4 text-center">
|
||||
<p class="text-gray-400">
|
||||
EDH Stats Tracker - Track your Commander games with style
|
||||
</p>
|
||||
<p class="text-gray-600 text-xs mt-3" id="version-footer">
|
||||
<!-- Version loaded dynamically -->
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1,430 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Games - EDH Stats Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Log and track your Magic: The Gathering EDH/Commander games"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="h-full" x-data="gameManager()">
|
||||
<!-- Navigation Header -->
|
||||
<header class="bg-slate-900 text-white shadow-lg">
|
||||
<nav class="container mx-auto px-4 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold font-mtg">Game Log</h1>
|
||||
<div class="flex space-x-4">
|
||||
<a
|
||||
href="/dashboard.html"
|
||||
class="hover:text-edh-accent transition-colors"
|
||||
>
|
||||
← Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- Header & Action -->
|
||||
<div class="mb-8 flex justify-between items-center">
|
||||
<h2 class="text-2xl font-semibold text-gray-900">Recent Games</h2>
|
||||
<div class="flex space-x-4">
|
||||
<button @click="exportGames()" class="btn btn-secondary">
|
||||
Export JSON
|
||||
</button>
|
||||
<button @click="showLogForm = !showLogForm" class="btn btn-primary">
|
||||
<span x-show="!showLogForm">Log New Game</span>
|
||||
<span x-show="showLogForm">Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Game Form -->
|
||||
<div
|
||||
x-show="showLogForm"
|
||||
x-transition
|
||||
class="mb-8 card border-2 border-edh-primary/20"
|
||||
>
|
||||
<h3 class="text-lg font-semibold mb-6">
|
||||
<span x-show="!editingGame">Log Game Details</span>
|
||||
<span x-show="editingGame">Edit Game</span>
|
||||
</h3>
|
||||
|
||||
<form @submit.prevent="handleLogGame">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Date -->
|
||||
<div>
|
||||
<label class="form-label">Date Played</label>
|
||||
<input
|
||||
type="date"
|
||||
x-model="formData.date"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Commander -->
|
||||
<div>
|
||||
<label class="form-label">Commander Played</label>
|
||||
<select
|
||||
x-model="formData.commanderId"
|
||||
class="form-select"
|
||||
required
|
||||
>
|
||||
<option value="">Select a commander...</option>
|
||||
<template x-for="commander in commanders" :key="commander.id">
|
||||
<option
|
||||
:value="commander.id"
|
||||
x-text="commander.name"
|
||||
></option>
|
||||
</template>
|
||||
</select>
|
||||
<p
|
||||
x-show="commanders.length === 0"
|
||||
class="text-sm text-gray-500 mt-1"
|
||||
>
|
||||
No commanders found.
|
||||
<a href="/commanders.html" class="text-edh-primary underline"
|
||||
>Add one first!</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Player Count -->
|
||||
<div>
|
||||
<label class="form-label">Number of Players</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model="formData.playerCount"
|
||||
min="2"
|
||||
max="8"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Rounds -->
|
||||
<div>
|
||||
<label class="form-label">Rounds Lasted</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model="formData.rounds"
|
||||
min="1"
|
||||
max="50"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Game Outcome -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="form-label mb-2 block">Did you win?</label>
|
||||
<div class="flex space-x-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
x-model="formData.won"
|
||||
:value="true"
|
||||
class="form-radio text-edh-primary"
|
||||
/>
|
||||
<span class="ml-2 text-green-700 font-medium"
|
||||
>Yes, I won! 🏆</span
|
||||
>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
x-model="formData.won"
|
||||
:value="false"
|
||||
class="form-radio text-edh-primary"
|
||||
/>
|
||||
<span class="ml-2 text-gray-700">No, maybe next time</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats -->
|
||||
<div class="md:col-span-2 space-y-3 p-4 bg-gray-50 rounded-lg">
|
||||
<label class="inline-flex items-center w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="formData.startingPlayerWon"
|
||||
class="form-checkbox text-edh-primary rounded"
|
||||
/>
|
||||
<span class="ml-2">Did the starting player win?</span>
|
||||
</label>
|
||||
|
||||
<label class="inline-flex items-center w-full cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="formData.solRingTurnOneWon"
|
||||
class="form-checkbox text-edh-primary rounded"
|
||||
/>
|
||||
<span class="ml-2">Did a Turn 1 Sol Ring player win?</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="col-span-1 md:col-span-2 w-full">
|
||||
<label class="form-label">Game Notes</label>
|
||||
<textarea
|
||||
x-model="formData.notes"
|
||||
rows="5"
|
||||
class="form-textarea w-full"
|
||||
placeholder="Any memorable moments, combos, or reasons for winning/losing..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
x-show="serverError"
|
||||
class="mt-4 p-3 bg-red-100 text-red-700 rounded-lg text-sm"
|
||||
x-text="serverError"
|
||||
></div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="editingGame ? cancelEdit() : (showLogForm = false)"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="editingGame ? editSubmitting : submitting"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<span x-show="editingGame && editSubmitting">Saving...</span>
|
||||
<span x-show="editingGame && !editSubmitting">Update Game</span>
|
||||
<span x-show="!editingGame && submitting">Saving...</span>
|
||||
<span x-show="!editingGame && !submitting">Log Game</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Games List -->
|
||||
<div class="space-y-4">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
x-show="loading && games.length === 0"
|
||||
class="flex justify-center py-12"
|
||||
>
|
||||
<div class="loading-spinner w-8 h-8"></div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
x-show="!loading && games.length === 0"
|
||||
class="text-center py-12 bg-white rounded-lg shadow"
|
||||
>
|
||||
<p class="text-gray-500 mb-4">No games logged yet.</p>
|
||||
<button
|
||||
@click="showLogForm = true"
|
||||
class="text-edh-primary font-medium hover:underline"
|
||||
>
|
||||
Log your first game!
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- List Items -->
|
||||
<template x-for="game in games" :key="game.id">
|
||||
<div
|
||||
class="card hover:shadow-md transition-shadow border-l-4"
|
||||
:class="game.won ? 'border-l-green-500' : 'border-l-gray-300'"
|
||||
>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<h3
|
||||
class="font-bold text-lg"
|
||||
x-text="game.commanderName || getCommanderName(game.commanderId)"
|
||||
></h3>
|
||||
<span
|
||||
x-show="game.won"
|
||||
class="px-2 py-0.5 rounded text-xs font-bold bg-green-100 text-green-800"
|
||||
>WIN</span
|
||||
>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">
|
||||
<span x-text="formatDate(game.date)"></span> •
|
||||
<span x-text="game.playerCount"></span> Players •
|
||||
<span x-text="game.rounds"></span> Rounds
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="editGame(game.id)"
|
||||
class="text-edh-accent hover:text-edh-primary transition-colors"
|
||||
title="Edit game"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 21C12 20.4477 12.4477 20 13 20H21C21.5523 20 22 20.4477 22 21C22 21.5523 21.5523 22 21 22H13C12.4477 22 12 21.5523 12 21Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M20.7736 8.09994C22.3834 6.48381 22.315 4.36152 21.113 3.06183C20.5268 2.4281 19.6926 2.0233 18.7477 2.00098C17.7993 1.97858 16.8167 2.34127 15.91 3.09985C15.8868 3.11925 15.8645 3.13969 15.8432 3.16111L2.87446 16.1816C2.31443 16.7438 2 17.5051 2 18.2987V19.9922C2 21.0937 2.89197 22 4.00383 22H5.68265C6.48037 22 7.24524 21.6823 7.80819 21.1171L20.7736 8.09994ZM17.2071 5.79295C16.8166 5.40243 16.1834 5.40243 15.7929 5.79295C15.4024 6.18348 15.4024 6.81664 15.7929 7.20717L16.7929 8.20717C17.1834 8.59769 17.8166 8.59769 18.2071 8.20717C18.5976 7.81664 18.5976 7.18348 18.2071 6.79295L17.2071 5.79295Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="deleteGame(game.id)"
|
||||
class="text-red-600 hover:text-red-800 transition-colors"
|
||||
title="Delete game"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 6H20M16 6L15.7294 5.18807C15.4671 4.40125 15.3359 4.00784 15.0927 3.71698C14.8779 3.46013 14.6021 3.26132 14.2905 3.13878C13.9376 3 13.523 3 12.6936 3H11.3064C10.477 3 10.0624 3 9.70951 3.13878C9.39792 3.26132 9.12208 3.46013 8.90729 3.71698C8.66405 4.00784 8.53292 4.40125 8.27064 5.18807L8 6M18 6V16.2C18 17.8802 18 18.7202 17.673 19.362C17.3854 19.9265 16.9265 20.3854 16.362 20.673C15.7202 21 14.8802 21 13.2 21H10.8C9.11984 21 8.27976 21 7.63803 20.673C7.07354 20.3854 6.6146 19.9265 6.32698 19.362C6 18.7202 6 17.8802 6 16.2V6M14 10V17M10 10V17"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="game.notes"
|
||||
class="mt-3 text-gray-600 text-sm bg-gray-50 p-2 rounded"
|
||||
>
|
||||
<span
|
||||
class="font-semibold text-gray-500 text-xs uppercase tracking-wide"
|
||||
>Notes:</span
|
||||
>
|
||||
<p x-text="game.notes"></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2 text-xs text-gray-500">
|
||||
<span
|
||||
x-show="game.startingPlayerWon"
|
||||
class="px-2 py-1 bg-gray-100 rounded"
|
||||
>Starting Player Won</span
|
||||
>
|
||||
<span
|
||||
x-show="game.solRingTurnOneWon"
|
||||
class="px-2 py-1 bg-yellow-50 text-yellow-700 rounded border border-yellow-200"
|
||||
>T1 Sol Ring Win</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Load More -->
|
||||
<div x-show="pagination.hasMore" class="flex justify-center pt-8">
|
||||
<button
|
||||
@click="loadMore()"
|
||||
:disabled="pagination.isLoadingMore"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': pagination.isLoadingMore }"
|
||||
class="btn btn-secondary flex justify-center items-center space-x-2"
|
||||
>
|
||||
<svg
|
||||
x-show="!pagination.isLoadingMore"
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
x-show="pagination.isLoadingMore"
|
||||
class="animate-spin h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span x-text="pagination.isLoadingMore ? 'Loading...' : 'Load More Games'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div
|
||||
x-show="deleteConfirm.show"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
@click.self="deleteConfirm.show = false"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-lg max-w-sm w-full">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Delete Game</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Are you sure you want to delete this game record? This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="deleteConfirm.show = false"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete()"
|
||||
:disabled="deleteConfirm.deleting"
|
||||
class="btn btn-primary bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<span x-show="!deleteConfirm.deleting">Delete Game</span>
|
||||
<span x-show="deleteConfirm.deleting">Deleting...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts --> <script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script src="/js/auth-guard.js"></script>
|
||||
<script src="/js/games.js"></script>
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 398 KiB |
@@ -1,272 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EDH Stats Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Track your Magic: The Gathering EDH/Commander games and statistics"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="min-h-full flex flex-col py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-center flex-1">
|
||||
<div class="w-full space-y-8 text-center" x-data="indexApp()">
|
||||
<div class="max-w-md mx-auto">
|
||||
<h1 class="text-4xl font-bold font-mtg text-edh-primary mb-4">
|
||||
EDH Stats
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 mb-8">
|
||||
Track your Commander games and statistics
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<a href="/login.html" class="btn btn-primary w-full">
|
||||
🎮 Login to Track Games
|
||||
</a>
|
||||
<a
|
||||
x-show="allowRegistration"
|
||||
href="/register.html"
|
||||
class="btn btn-secondary w-full"
|
||||
>
|
||||
📝 Create New Account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Slideshow Section -->
|
||||
<div class="mt-12 max-w-5xl mx-auto">
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-lg overflow-hidden"
|
||||
x-data="slideshow()"
|
||||
>
|
||||
<!-- Slides Container with Arrow Navigation -->
|
||||
<div class="relative bg-gray-100 pt-9 pb-9 overflow-hidden">
|
||||
<!-- Image Container -->
|
||||
<div
|
||||
class="relative aspect-video flex items-center justify-center"
|
||||
>
|
||||
<template x-for="(slide, index) in slides" :key="index">
|
||||
<img
|
||||
:src="slide.src"
|
||||
:alt="slide.alt"
|
||||
x-show="currentSlide === index"
|
||||
x-transition:fade.duration.500ms
|
||||
class="w-full h-full object-contain absolute inset-0"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
<div
|
||||
x-show="loading"
|
||||
class="absolute inset-0 flex items-center justify-center bg-gray-200"
|
||||
>
|
||||
<div
|
||||
class="animate-spin rounded-full h-12 w-12 border-b-2 border-edh-primary"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Left Arrow Button -->
|
||||
<button
|
||||
@click="previousSlide()"
|
||||
class="absolute left-2 top-1/2 transform -translate-y-1/2 text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded-full p-2 transition-colors"
|
||||
title="Previous slide"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Right Arrow Button -->
|
||||
<button
|
||||
@click="nextSlide()"
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-600 hover:text-gray-800 hover:bg-gray-200 rounded-full p-2 transition-colors"
|
||||
title="Next slide"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Play/Pause Button -->
|
||||
<button
|
||||
@click="toggleAutoPlay()"
|
||||
:class="autoPlay ? 'bg-edh-primary text-white' : 'bg-gray-300 text-gray-700'"
|
||||
class="absolute top-4 right-4 px-3 py-1 rounded text-sm font-medium hover:opacity-80 transition-opacity"
|
||||
:title="autoPlay ? 'Pause slideshow' : 'Play slideshow'"
|
||||
>
|
||||
<span x-show="autoPlay">⏸</span>
|
||||
<span x-show="!autoPlay">▶</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Slide Indicators (Dots) -->
|
||||
<div class="bg-white px-6 py-4 flex justify-center gap-2">
|
||||
<template x-for="(slide, index) in slides" :key="index">
|
||||
<button
|
||||
@click="goToSlide(index)"
|
||||
:class="currentSlide === index ? 'bg-edh-primary' : 'bg-gray-300 hover:bg-gray-400'"
|
||||
class="w-2 h-2 rounded-full transition-colors"
|
||||
:title="`Go to slide ${index + 1}: ${slide.title}`"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 mt-4 text-sm text-center">
|
||||
Explore all the features of EDH Stats Tracker - track games, manage
|
||||
commanders, view statistics, and use the round timer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script>
|
||||
function indexApp() {
|
||||
return {
|
||||
allowRegistration: true,
|
||||
|
||||
async init() {
|
||||
await this.checkRegistrationConfig()
|
||||
},
|
||||
|
||||
async checkRegistrationConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/config')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
this.allowRegistration = data.allowRegistration
|
||||
} else {
|
||||
// Default to true if endpoint fails
|
||||
this.allowRegistration = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check registration config:', error)
|
||||
// Default to true if request fails
|
||||
this.allowRegistration = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function slideshow() {
|
||||
return {
|
||||
currentSlide: 0,
|
||||
autoPlay: true,
|
||||
autoPlayInterval: null,
|
||||
loading: false,
|
||||
slides: [
|
||||
{
|
||||
src: '/images/commanders.png',
|
||||
alt: 'Commander management interface for adding and editing decks with color selection',
|
||||
title: 'Commanders'
|
||||
},
|
||||
{
|
||||
src: '/images/logs.png',
|
||||
alt: 'Game logging form for recording game results, player count, and outcomes',
|
||||
title: 'Log Games'
|
||||
},
|
||||
{
|
||||
src: '/images/round_timer.png',
|
||||
alt: 'Real-time round counter with automatic game timing and elapsed time tracking',
|
||||
title: 'Round Timer'
|
||||
},
|
||||
{
|
||||
src: '/images/stats.png',
|
||||
alt: 'Detailed statistics showing win rates by color, player count, and commander performance',
|
||||
title: 'Statistics'
|
||||
}
|
||||
],
|
||||
|
||||
init() {
|
||||
this.startAutoPlay()
|
||||
},
|
||||
|
||||
nextSlide() {
|
||||
this.currentSlide = (this.currentSlide + 1) % this.slides.length
|
||||
this.restartAutoPlay()
|
||||
},
|
||||
|
||||
previousSlide() {
|
||||
this.currentSlide =
|
||||
(this.currentSlide - 1 + this.slides.length) % this.slides.length
|
||||
this.restartAutoPlay()
|
||||
},
|
||||
|
||||
goToSlide(index) {
|
||||
this.currentSlide = index
|
||||
this.restartAutoPlay()
|
||||
},
|
||||
|
||||
toggleAutoPlay() {
|
||||
this.autoPlay = !this.autoPlay
|
||||
if (this.autoPlay) {
|
||||
this.startAutoPlay()
|
||||
} else {
|
||||
this.stopAutoPlay()
|
||||
}
|
||||
},
|
||||
|
||||
startAutoPlay() {
|
||||
this.autoPlayInterval = setInterval(() => {
|
||||
this.currentSlide = (this.currentSlide + 1) % this.slides.length
|
||||
}, 5000)
|
||||
},
|
||||
|
||||
stopAutoPlay() {
|
||||
if (this.autoPlayInterval) {
|
||||
clearInterval(this.autoPlayInterval)
|
||||
this.autoPlayInterval = null
|
||||
}
|
||||
},
|
||||
|
||||
restartAutoPlay() {
|
||||
this.stopAutoPlay()
|
||||
if (this.autoPlay) {
|
||||
this.startAutoPlay()
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.stopAutoPlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('indexApp', indexApp)
|
||||
Alpine.data('slideshow', slideshow)
|
||||
})
|
||||
</script>
|
||||
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,245 +0,0 @@
|
||||
// Main application Alpine.js data and methods
|
||||
function app() {
|
||||
return {
|
||||
currentUser: null,
|
||||
loading: true,
|
||||
mobileMenuOpen: false,
|
||||
roundCounter: {
|
||||
active: false,
|
||||
current: 1,
|
||||
startTime: null
|
||||
},
|
||||
stats: {
|
||||
totalGames: 0,
|
||||
winRate: 0,
|
||||
totalCommanders: 0,
|
||||
avgRounds: 0
|
||||
},
|
||||
recentGames: [],
|
||||
topCommanders: [],
|
||||
|
||||
async init() {
|
||||
// Check authentication on load
|
||||
await this.checkAuth()
|
||||
|
||||
// Load dashboard data if authenticated
|
||||
if (this.currentUser) {
|
||||
await this.loadDashboardData()
|
||||
}
|
||||
|
||||
// Load round counter from localStorage
|
||||
this.loadRoundCounter()
|
||||
|
||||
// Listen for page visibility changes to refresh stats
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden && localStorage.getItem('edh-stats-dirty')) {
|
||||
localStorage.removeItem('edh-stats-dirty')
|
||||
this.loadDashboardData()
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async checkAuth() {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
if (token) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
this.currentUser = data.user
|
||||
} else {
|
||||
// Token invalid, remove it
|
||||
localStorage.removeItem('edh-stats-token')
|
||||
sessionStorage.removeItem('edh-stats-token')
|
||||
if (window.location.pathname !== '/login.html') {
|
||||
window.location.href = '/login.html'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error)
|
||||
localStorage.removeItem('edh-stats-token')
|
||||
sessionStorage.removeItem('edh-stats-token')
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
window.location.pathname !== '/login.html' &&
|
||||
window.location.pathname !== '/register.html'
|
||||
) {
|
||||
window.location.href = '/login.html'
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
},
|
||||
|
||||
async loadDashboardData() {
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
|
||||
// Load user stats
|
||||
const statsResponse = await fetch('/api/stats/overview', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (statsResponse.ok) {
|
||||
this.stats = await statsResponse.json()
|
||||
}
|
||||
|
||||
// Load recent games
|
||||
const gamesResponse = await fetch('/api/games?limit=5', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (gamesResponse.ok) {
|
||||
const gamesData = await gamesResponse.json()
|
||||
this.recentGames = gamesData.games || []
|
||||
}
|
||||
|
||||
// Load top commanders with stats
|
||||
const commandersResponse = await fetch('/api/stats/commanders', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (commandersResponse.ok) {
|
||||
const commandersData = await commandersResponse.json()
|
||||
// Get commanders stats and limit to 5 (already sorted by backend)
|
||||
const commanders = Array.isArray(commandersData.stats)
|
||||
? commandersData.stats
|
||||
: []
|
||||
this.topCommanders = commanders.slice(0, 5)
|
||||
} else {
|
||||
this.topCommanders = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error)
|
||||
}
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('edh-stats-token')
|
||||
sessionStorage.removeItem('edh-stats-token')
|
||||
window.location.href = '/login.html'
|
||||
},
|
||||
|
||||
// Round counter methods
|
||||
startRoundCounter() {
|
||||
this.roundCounter.active = true
|
||||
this.roundCounter.current = 1
|
||||
this.roundCounter.startTime = new Date()
|
||||
this.saveRoundCounter()
|
||||
},
|
||||
|
||||
incrementRound() {
|
||||
this.roundCounter.current++
|
||||
this.saveRoundCounter()
|
||||
},
|
||||
|
||||
resetRoundCounter() {
|
||||
this.roundCounter.active = false
|
||||
this.roundCounter.current = 1
|
||||
this.roundCounter.startTime = null
|
||||
this.saveRoundCounter()
|
||||
},
|
||||
|
||||
saveRoundCounter() {
|
||||
localStorage.setItem(
|
||||
'edh-round-counter',
|
||||
JSON.stringify({
|
||||
active: this.roundCounter.active,
|
||||
current: this.roundCounter.current,
|
||||
startTime: this.roundCounter.startTime
|
||||
})
|
||||
)
|
||||
},
|
||||
|
||||
loadRoundCounter() {
|
||||
const saved = localStorage.getItem('edh-round-counter')
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved)
|
||||
this.roundCounter = data
|
||||
|
||||
// If counter is older than 24 hours, reset it
|
||||
if (
|
||||
data.startTime &&
|
||||
new Date() - new Date(data.startTime) > 24 * 60 * 60 * 1000
|
||||
) {
|
||||
this.resetRoundCounter()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Utility methods
|
||||
formatDate(dateString) {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year:
|
||||
date.getFullYear() !== new Date().getFullYear()
|
||||
? 'numeric'
|
||||
: undefined
|
||||
})
|
||||
},
|
||||
|
||||
getColorName(color) {
|
||||
const colorNames = {
|
||||
W: 'White',
|
||||
U: 'Blue',
|
||||
B: 'Black',
|
||||
R: 'Red',
|
||||
G: 'Green'
|
||||
}
|
||||
return colorNames[color] || color
|
||||
},
|
||||
|
||||
// API helper method
|
||||
async apiCall(endpoint, options = {}) {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` })
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
headers: {
|
||||
...defaultOptions.headers,
|
||||
...options.headers
|
||||
}
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token expired, redirect to login
|
||||
this.logout()
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make the app function globally available
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('app', app)
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
// Authentication Check - Validates token with backend and redirects if already authenticated
|
||||
// Only runs on login.html and register.html pages
|
||||
(function() {
|
||||
const currentPage = window.location.pathname.split('/').pop() || 'index.html'
|
||||
const authPages = ['login.html', 'register.html']
|
||||
|
||||
// Only run on auth pages
|
||||
if (!authPages.includes(currentPage)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if token exists in storage
|
||||
const token = localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')
|
||||
|
||||
// If no token, user is already logged out, no need to check
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate token with backend before redirecting
|
||||
validateToken(token)
|
||||
|
||||
async function validateToken(token) {
|
||||
try {
|
||||
const response = await fetch('/api/auth/me', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Token is valid, user is authenticated - redirect to dashboard
|
||||
window.location.href = '/dashboard.html'
|
||||
} else if (response.status === 401) {
|
||||
// Token is invalid or expired, clear storage
|
||||
localStorage.removeItem('edh-stats-token')
|
||||
sessionStorage.removeItem('edh-stats-token')
|
||||
// User stays on login/register page
|
||||
} else {
|
||||
// Other error, log but don't block user
|
||||
console.warn('Token validation failed with status:', response.status)
|
||||
}
|
||||
} catch (error) {
|
||||
// Network error or other issue, log but don't block user
|
||||
console.warn('Token validation error:', error)
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -1,39 +0,0 @@
|
||||
// Authentication Guard - Checks for valid token on page load and redirects to login if needed
|
||||
(function() {
|
||||
// Check if we're on a protected page (not login/register/index/status)
|
||||
const unprotectedPages = ['login.html', 'register.html', 'index.html', 'status.html']
|
||||
const currentPage = window.location.pathname.split('/').pop() || 'index.html'
|
||||
|
||||
if (!unprotectedPages.includes(currentPage)) {
|
||||
// This is a protected page, check for valid token
|
||||
const token = localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')
|
||||
|
||||
if (!token) {
|
||||
// No token, redirect to login
|
||||
window.location.href = '/login.html'
|
||||
}
|
||||
}
|
||||
|
||||
// Override global fetch to handle 401 responses
|
||||
const originalFetch = window.fetch
|
||||
window.fetch = function(...args) {
|
||||
return originalFetch.apply(this, args)
|
||||
.then(response => {
|
||||
if (response.status === 401) {
|
||||
// Unauthorized - clear tokens and redirect to login
|
||||
localStorage.removeItem('edh-stats-token')
|
||||
sessionStorage.removeItem('edh-stats-token')
|
||||
window.location.href = '/login.html'
|
||||
// Throw error to prevent further processing
|
||||
throw new Error('Authentication required - redirecting to login')
|
||||
}
|
||||
return response
|
||||
})
|
||||
.catch(error => {
|
||||
// Re-throw unless it's our auth error
|
||||
if (error.message !== 'Authentication required - redirecting to login') {
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
})()
|
||||
@@ -1,279 +0,0 @@
|
||||
// Authentication form logic for login and registration
|
||||
function loginForm() {
|
||||
return {
|
||||
formData: {
|
||||
username: '',
|
||||
password: '',
|
||||
remember: false
|
||||
},
|
||||
errors: {},
|
||||
showPassword: false,
|
||||
loading: false,
|
||||
serverError: '',
|
||||
|
||||
validateUsername() {
|
||||
if (!this.formData.username.trim()) {
|
||||
this.errors.username = 'Username is required'
|
||||
} else if (this.formData.username.length < 3) {
|
||||
this.errors.username = 'Username must be at least 3 characters'
|
||||
} else {
|
||||
this.errors.username = ''
|
||||
}
|
||||
},
|
||||
|
||||
validatePassword() {
|
||||
if (!this.formData.password) {
|
||||
this.errors.password = 'Password is required'
|
||||
} else if (this.formData.password.length < 8) {
|
||||
this.errors.password = 'Password must be at least 8 characters'
|
||||
} else {
|
||||
this.errors.password = ''
|
||||
}
|
||||
},
|
||||
|
||||
async handleLogin() {
|
||||
// Validate form
|
||||
this.validateUsername()
|
||||
this.validatePassword()
|
||||
|
||||
if (this.errors.username || this.errors.password) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.serverError = ''
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: this.formData.username,
|
||||
password: this.formData.password,
|
||||
remember: this.formData.remember
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
// Store token
|
||||
if (this.formData.remember) {
|
||||
localStorage.setItem('edh-stats-token', data.token)
|
||||
} else {
|
||||
sessionStorage.setItem('edh-stats-token', data.token)
|
||||
}
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/dashboard.html'
|
||||
} else {
|
||||
this.serverError = data.message || 'Login failed'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
this.serverError = 'Network error. Please try again.'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerForm() {
|
||||
return {
|
||||
formData: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
terms: false
|
||||
},
|
||||
errors: {},
|
||||
showPassword: false,
|
||||
showConfirmPassword: false,
|
||||
loading: false,
|
||||
serverError: '',
|
||||
successMessage: '',
|
||||
allowRegistration: true,
|
||||
|
||||
async init() {
|
||||
// Check registration config on page load
|
||||
await this.checkRegistrationConfig()
|
||||
},
|
||||
|
||||
async checkRegistrationConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/config')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
this.allowRegistration = data.allowRegistration
|
||||
} else {
|
||||
this.allowRegistration = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check registration config:', error)
|
||||
this.allowRegistration = true
|
||||
}
|
||||
},
|
||||
|
||||
validateUsername() {
|
||||
if (!this.formData.username.trim()) {
|
||||
this.errors.username = 'Username is required'
|
||||
} else if (this.formData.username.length < 3) {
|
||||
this.errors.username = 'Username must be at least 3 characters'
|
||||
} else if (this.formData.username.length > 50) {
|
||||
this.errors.username = 'Username must be less than 50 characters'
|
||||
} else if (!/^[a-zA-Z0-9_-]+$/.test(this.formData.username)) {
|
||||
this.errors.username =
|
||||
'Username can only contain letters, numbers, underscores, and hyphens'
|
||||
} else {
|
||||
this.errors.username = ''
|
||||
}
|
||||
},
|
||||
|
||||
validateEmail() {
|
||||
if (
|
||||
this.formData.email &&
|
||||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.formData.email)
|
||||
) {
|
||||
this.errors.email = 'Please enter a valid email address'
|
||||
} else {
|
||||
this.errors.email = ''
|
||||
}
|
||||
},
|
||||
|
||||
validatePassword() {
|
||||
if (!this.formData.password) {
|
||||
this.errors.password = 'Password is required'
|
||||
} else if (this.formData.password.length < 8) {
|
||||
this.errors.password = 'Password must be at least 8 characters'
|
||||
} else if (this.formData.password.length > 100) {
|
||||
this.errors.password = 'Password must be less than 100 characters'
|
||||
} else if (!/(?=.*[a-z])/.test(this.formData.password)) {
|
||||
this.errors.password =
|
||||
'Password must contain at least one lowercase letter'
|
||||
} else if (!/(?=.*[A-Z])/.test(this.formData.password)) {
|
||||
this.errors.password =
|
||||
'Password must contain at least one uppercase letter'
|
||||
} else if (!/(?=.*\d)/.test(this.formData.password)) {
|
||||
this.errors.password = 'Password must contain at least one number'
|
||||
} else {
|
||||
this.errors.password = ''
|
||||
}
|
||||
},
|
||||
|
||||
validateConfirmPassword() {
|
||||
if (!this.formData.confirmPassword) {
|
||||
this.errors.confirmPassword = 'Please confirm your password'
|
||||
} else if (this.formData.password !== this.formData.confirmPassword) {
|
||||
this.errors.confirmPassword = 'Passwords do not match'
|
||||
} else {
|
||||
this.errors.confirmPassword = ''
|
||||
}
|
||||
},
|
||||
|
||||
validateTerms() {
|
||||
if (!this.formData.terms) {
|
||||
this.errors.terms = 'You must agree to the Terms of Service'
|
||||
} else {
|
||||
this.errors.terms = ''
|
||||
}
|
||||
},
|
||||
|
||||
async handleRegister() {
|
||||
// Validate all fields
|
||||
this.validateUsername()
|
||||
this.validateEmail()
|
||||
this.validatePassword()
|
||||
this.validateConfirmPassword()
|
||||
this.validateTerms()
|
||||
|
||||
if (Object.values(this.errors).some((error) => error)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.serverError = ''
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: this.formData.username,
|
||||
email: this.formData.email || undefined,
|
||||
password: this.formData.password
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
// Store token and redirect
|
||||
this.successMessage = 'Account created successfully! Redirecting...'
|
||||
localStorage.setItem('edh-stats-token', data.token)
|
||||
setTimeout(() => {
|
||||
window.location.href = '/dashboard.html'
|
||||
}, 1000)
|
||||
} else {
|
||||
if (data.details && Array.isArray(data.details)) {
|
||||
this.serverError = data.details.join(', ')
|
||||
} else {
|
||||
this.serverError = data.message || 'Registration failed'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
this.serverError = 'Network error. Please try again.'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make functions globally available
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('loginForm', loginForm)
|
||||
Alpine.data('registerForm', registerForm)
|
||||
})
|
||||
|
||||
// Utility function to get auth token
|
||||
function getAuthToken() {
|
||||
return (
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
)
|
||||
}
|
||||
|
||||
// API helper with automatic token handling
|
||||
async function authenticatedFetch(url, options = {}) {
|
||||
const token = getAuthToken()
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` })
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token expired or invalid, redirect to login
|
||||
localStorage.removeItem('edh-stats-token')
|
||||
sessionStorage.removeItem('edh-stats-token')
|
||||
window.location.href = '/login.html'
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
// Commander management Alpine.js components
|
||||
function commanderManager() {
|
||||
return {
|
||||
// State
|
||||
showAddForm: false,
|
||||
editingCommander: null,
|
||||
commanders: [],
|
||||
popular: [],
|
||||
loading: false,
|
||||
showPopular: false,
|
||||
searchQuery: '',
|
||||
submitting: false,
|
||||
editSubmitting: false,
|
||||
serverError: '',
|
||||
|
||||
// Form Data
|
||||
newCommander: {
|
||||
name: '',
|
||||
colors: []
|
||||
},
|
||||
errors: {},
|
||||
editErrors: {},
|
||||
|
||||
// Constants
|
||||
mtgColors: [
|
||||
{ id: 'W', name: 'White', hex: '#F0E6D2' },
|
||||
{ id: 'U', name: 'Blue', hex: '#0E68AB' },
|
||||
{ id: 'B', name: 'Black', hex: '#2C2B2D' },
|
||||
{ id: 'R', name: 'Red', hex: '#C44536' },
|
||||
{ id: 'G', name: 'Green', hex: '#5A7A3B' }
|
||||
],
|
||||
|
||||
// Lifecycle
|
||||
async init() {
|
||||
await this.loadCommanders()
|
||||
},
|
||||
|
||||
// API Methods
|
||||
async loadCommanders() {
|
||||
this.loading = true
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
const response = await fetch('/api/commanders', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
this.commanders = data.commanders || []
|
||||
} else {
|
||||
this.serverError = 'Failed to load commanders'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load commanders error:', error)
|
||||
this.serverError = 'Network error occurred'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async loadPopular() {
|
||||
this.loading = true
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
const response = await fetch('/api/commanders/popular', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
this.popular = data.commanders || []
|
||||
// Swap commanders with popular for display
|
||||
const temp = this.commanders
|
||||
this.commanders = this.popular
|
||||
this.popular = temp
|
||||
this.showPopular = true
|
||||
} else {
|
||||
this.serverError = 'Failed to load popular commanders'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load popular error:', error)
|
||||
this.serverError = 'Network error occurred'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async togglePopular() {
|
||||
if (this.showPopular) {
|
||||
// Show all commanders
|
||||
const temp = this.commanders
|
||||
this.commanders = this.popular
|
||||
this.popular = temp
|
||||
this.showPopular = false
|
||||
} else {
|
||||
// Show popular commanders
|
||||
await this.loadPopular()
|
||||
}
|
||||
},
|
||||
|
||||
// Validation
|
||||
validateCommanderName() {
|
||||
if (!this.newCommander.name.trim()) {
|
||||
this.errors.name = 'Commander name is required'
|
||||
} else if (this.newCommander.name.length < 2) {
|
||||
this.errors.name = 'Commander name must be at least 2 characters'
|
||||
} else if (this.newCommander.name.length > 100) {
|
||||
this.errors.name = 'Commander name must be less than 100 characters'
|
||||
} else {
|
||||
delete this.errors.name
|
||||
}
|
||||
},
|
||||
|
||||
validateEditCommanderName() {
|
||||
if (!this.editingCommander) return
|
||||
if (!this.editingCommander.name.trim()) {
|
||||
this.editErrors.name = 'Commander name is required'
|
||||
} else if (this.editingCommander.name.length < 2) {
|
||||
this.editErrors.name = 'Commander name must be at least 2 characters'
|
||||
} else if (this.editingCommander.name.length > 100) {
|
||||
this.editErrors.name = 'Commander name must be less than 100 characters'
|
||||
} else {
|
||||
delete this.editErrors.name
|
||||
}
|
||||
},
|
||||
|
||||
// Actions
|
||||
async handleAddCommander() {
|
||||
this.validateCommanderName()
|
||||
if (this.errors.name) return
|
||||
|
||||
this.submitting = true
|
||||
this.serverError = ''
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
const response = await fetch('/api/commanders', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(this.newCommander)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
this.commanders.unshift(data.commander)
|
||||
this.resetAddForm()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
// Use message if available, otherwise extract from details array
|
||||
if (errorData.message) {
|
||||
this.serverError = errorData.message
|
||||
} else if (errorData.details && Array.isArray(errorData.details)) {
|
||||
this.serverError = errorData.details
|
||||
.map((err) => err.message || err)
|
||||
.join(', ')
|
||||
} else {
|
||||
this.serverError = 'Failed to create commander'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Add commander error:', error)
|
||||
this.serverError = 'Network error occurred'
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
},
|
||||
|
||||
async handleUpdateCommander() {
|
||||
if (!this.editingCommander) return
|
||||
|
||||
this.validateEditCommanderName()
|
||||
if (this.editErrors.name) return
|
||||
|
||||
this.editSubmitting = true
|
||||
this.serverError = ''
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
const response = await fetch(
|
||||
`/api/commanders/${this.editingCommander.id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: this.editingCommander.name,
|
||||
colors: this.editingCommander.colors
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const index = this.commanders.findIndex(
|
||||
(c) => c.id === this.editingCommander.id
|
||||
)
|
||||
if (index !== -1) {
|
||||
this.commanders[index] = data.commander
|
||||
}
|
||||
this.cancelEdit()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
// Format validation errors if they exist
|
||||
if (errorData.details && Array.isArray(errorData.details)) {
|
||||
this.serverError = errorData.details
|
||||
.map((err) => err.message || err)
|
||||
.join(', ')
|
||||
} else {
|
||||
this.serverError = errorData.message || 'Failed to update commander'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update commander error:', error)
|
||||
this.serverError = 'Network error occurred'
|
||||
} finally {
|
||||
this.editSubmitting = false
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCommander(commander) {
|
||||
if (!confirm(`Are you sure you want to delete "${commander.name}"?`))
|
||||
return
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
const response = await fetch(`/api/commanders/${commander.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
this.commanders = this.commanders.filter((c) => c.id !== commander.id)
|
||||
} else {
|
||||
this.serverError = 'Failed to delete commander'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error)
|
||||
this.serverError = 'Network error occurred'
|
||||
}
|
||||
},
|
||||
|
||||
// Search
|
||||
async searchCommanders() {
|
||||
if (!this.searchQuery.trim()) {
|
||||
await this.loadCommanders()
|
||||
return
|
||||
}
|
||||
this.loading = true
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
const response = await fetch(
|
||||
`/api/commanders?q=${encodeURIComponent(this.searchQuery)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
this.commanders = data.commanders || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
debounceSearch() {
|
||||
clearTimeout(this._searchTimeout)
|
||||
this._searchTimeout = setTimeout(() => {
|
||||
if (this.showPopular) {
|
||||
this.loadCommanders() // Reset to normal view if searching
|
||||
this.showPopular = false
|
||||
}
|
||||
this.searchCommanders()
|
||||
}, 300)
|
||||
},
|
||||
|
||||
// UI Helpers
|
||||
toggleNewColor(colorId) {
|
||||
const index = this.newCommander.colors.indexOf(colorId)
|
||||
if (index > -1) {
|
||||
this.newCommander.colors.splice(index, 1)
|
||||
} else {
|
||||
this.newCommander.colors.push(colorId)
|
||||
}
|
||||
},
|
||||
|
||||
toggleEditColor(colorId) {
|
||||
if (!this.editingCommander) return
|
||||
if (!this.editingCommander.colors) this.editingCommander.colors = []
|
||||
const index = this.editingCommander.colors.indexOf(colorId)
|
||||
if (index > -1) {
|
||||
this.editingCommander.colors = this.editingCommander.colors.filter(
|
||||
(c) => c !== colorId
|
||||
)
|
||||
} else {
|
||||
this.editingCommander.colors.push(colorId)
|
||||
}
|
||||
},
|
||||
|
||||
isNewColorSelected(colorId) {
|
||||
return this.newCommander.colors.includes(colorId)
|
||||
},
|
||||
|
||||
isEditColorSelected(colorId) {
|
||||
return (
|
||||
this.editingCommander &&
|
||||
this.editingCommander.colors &&
|
||||
this.editingCommander.colors.includes(colorId)
|
||||
)
|
||||
},
|
||||
|
||||
getButtonClass(isSelected) {
|
||||
return isSelected
|
||||
? 'ring-2 ring-offset-2 border-white'
|
||||
: 'ring-1 ring-offset-1 border-gray-300 hover:border-gray-400'
|
||||
},
|
||||
|
||||
// Form Management
|
||||
editCommander(commander) {
|
||||
this.editingCommander = JSON.parse(JSON.stringify(commander))
|
||||
if (!Array.isArray(this.editingCommander.colors)) {
|
||||
this.editingCommander.colors = []
|
||||
}
|
||||
this.editErrors = {}
|
||||
this.serverError = ''
|
||||
},
|
||||
|
||||
cancelEdit() {
|
||||
this.editingCommander = null
|
||||
this.editErrors = {}
|
||||
},
|
||||
|
||||
resetAddForm() {
|
||||
this.showAddForm = false
|
||||
this.newCommander = { name: '', colors: [] }
|
||||
this.errors = {}
|
||||
this.serverError = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global Utilities
|
||||
function getColorName(colorId) {
|
||||
const map = { W: 'White', U: 'Blue', B: 'Black', R: 'Red', G: 'Green' }
|
||||
return map[colorId] || colorId
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
// Register Alpine component
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('commanderManager', commanderManager)
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
// Load and inject footer.html into the page
|
||||
(async function loadFooter() {
|
||||
try {
|
||||
const response = await fetch('/footer.html');
|
||||
if (response.ok) {
|
||||
const footerHTML = await response.text();
|
||||
// Create a temporary container to parse the HTML
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = footerHTML;
|
||||
// Append the actual footer element (not the temp div)
|
||||
const footerElement = temp.querySelector('footer');
|
||||
if (footerElement) {
|
||||
document.body.appendChild(footerElement);
|
||||
}
|
||||
|
||||
// Load and display version in footer after it's been injected
|
||||
loadVersion();
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Footer not available');
|
||||
}
|
||||
})();
|
||||
|
||||
// Load and display version in footer
|
||||
async function loadVersion() {
|
||||
try {
|
||||
const response = await fetch('/version.txt');
|
||||
if (response.ok) {
|
||||
const version = await response.text();
|
||||
const versionEl = document.getElementById('version-footer');
|
||||
if (versionEl) {
|
||||
versionEl.textContent = `v${version.trim()}`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Version file not available');
|
||||
}
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
// Game management Alpine.js component
|
||||
function gameManager() {
|
||||
return {
|
||||
showLogForm: false,
|
||||
games: [],
|
||||
commanders: [],
|
||||
loading: false,
|
||||
submitting: false,
|
||||
editSubmitting: false,
|
||||
editingGame: null,
|
||||
serverError: '',
|
||||
|
||||
// Delete confirmation modal
|
||||
deleteConfirm: {
|
||||
show: false,
|
||||
gameId: null,
|
||||
deleting: false
|
||||
},
|
||||
|
||||
// Game form data
|
||||
newGame: {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
commanderId: '',
|
||||
playerCount: 4,
|
||||
won: false,
|
||||
rounds: 8,
|
||||
startingPlayerWon: false,
|
||||
solRingTurnOneWon: false,
|
||||
notes: ''
|
||||
},
|
||||
|
||||
// Pagination - load more pattern
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
hasMore: false,
|
||||
isLoadingMore: false
|
||||
},
|
||||
|
||||
// Computed form data - returns editingGame if editing, otherwise newGame
|
||||
get formData() {
|
||||
return this.editingGame || this.newGame
|
||||
},
|
||||
|
||||
async init() {
|
||||
await Promise.all([this.loadCommanders(), this.loadGames()])
|
||||
this.loadPrefilled()
|
||||
},
|
||||
|
||||
async reloadStats() {
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
const response = await fetch('/api/stats/overview', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Set a flag for dashboard to refresh when user navigates back
|
||||
localStorage.setItem('edh-stats-dirty', 'true')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reload stats:', error)
|
||||
}
|
||||
},
|
||||
|
||||
loadPrefilled() {
|
||||
const prefilled = localStorage.getItem('edh-prefill-game')
|
||||
if (prefilled) {
|
||||
try {
|
||||
const data = JSON.parse(prefilled)
|
||||
|
||||
// Populate the form with prefilled values
|
||||
this.newGame.date =
|
||||
data.date || new Date().toISOString().split('T')[0]
|
||||
this.newGame.rounds = data.rounds || 8
|
||||
this.newGame.notes =
|
||||
`Ended after ${data.rounds} rounds in ${data.duration}.\nAverage time/round: ${data.avgTimePerRound}` ||
|
||||
''
|
||||
|
||||
// Show the form automatically
|
||||
this.showLogForm = true
|
||||
|
||||
// Clear the prefilled data from localStorage
|
||||
localStorage.removeItem('edh-prefill-game')
|
||||
|
||||
// Scroll to the form
|
||||
setTimeout(() => {
|
||||
document
|
||||
.querySelector('form')
|
||||
?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, 100)
|
||||
} catch (error) {
|
||||
console.error('Error loading prefilled game data:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async loadCommanders() {
|
||||
try {
|
||||
const response = await fetch('/api/commanders', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
this.commanders = data.commanders || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load commanders error:', error)
|
||||
}
|
||||
},
|
||||
|
||||
async loadGames() {
|
||||
this.loading = true
|
||||
this.serverError = ''
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/games?limit=${this.pagination.limit}&offset=${this.pagination.offset}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
this.games = data.games || []
|
||||
this.pagination.hasMore = data.pagination?.hasMore || false
|
||||
} else {
|
||||
this.serverError = 'Failed to load games'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load games error:', error)
|
||||
this.serverError = 'Network error occurred'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async loadMore() {
|
||||
this.pagination.isLoadingMore = true
|
||||
this.serverError = ''
|
||||
|
||||
try {
|
||||
this.pagination.offset += this.pagination.limit
|
||||
const response = await fetch(
|
||||
`/api/games?limit=${this.pagination.limit}&offset=${this.pagination.offset}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
this.games = [...this.games, ...(data.games || [])]
|
||||
this.pagination.hasMore = data.pagination?.hasMore || false
|
||||
} else {
|
||||
this.serverError = 'Failed to load more games'
|
||||
// Revert offset on error
|
||||
this.pagination.offset -= this.pagination.limit
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load more games error:', error)
|
||||
this.serverError = 'Network error occurred'
|
||||
// Revert offset on error
|
||||
this.pagination.offset -= this.pagination.limit
|
||||
} finally {
|
||||
this.pagination.isLoadingMore = false
|
||||
}
|
||||
},
|
||||
|
||||
async handleLogGame() {
|
||||
this.serverError = ''
|
||||
|
||||
// Basic validation
|
||||
if (!this.formData.commanderId) {
|
||||
this.serverError = 'Please select a commander'
|
||||
return
|
||||
}
|
||||
|
||||
if (this.editingGame) {
|
||||
await this.handleUpdateGame()
|
||||
} else {
|
||||
await this.handleCreateGame()
|
||||
}
|
||||
},
|
||||
|
||||
async handleCreateGame() {
|
||||
this.submitting = true
|
||||
|
||||
try {
|
||||
// Ensure boolean values are actual booleans, not strings
|
||||
const payload = {
|
||||
date: this.newGame.date,
|
||||
commanderId: parseInt(this.newGame.commanderId),
|
||||
playerCount: parseInt(this.newGame.playerCount),
|
||||
rounds: parseInt(this.newGame.rounds),
|
||||
won: this.newGame.won === true || this.newGame.won === 'true',
|
||||
startingPlayerWon:
|
||||
this.newGame.startingPlayerWon === true ||
|
||||
this.newGame.startingPlayerWon === 'true',
|
||||
solRingTurnOneWon:
|
||||
this.newGame.solRingTurnOneWon === true ||
|
||||
this.newGame.solRingTurnOneWon === 'true'
|
||||
}
|
||||
|
||||
// Only include notes if it's not empty
|
||||
if (this.newGame.notes && this.newGame.notes.trim()) {
|
||||
payload.notes = this.newGame.notes
|
||||
}
|
||||
|
||||
const response = await fetch('/api/games', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
this.games.unshift(data.game)
|
||||
this.resetForm()
|
||||
this.showLogForm = false
|
||||
await this.reloadStats()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
this.serverError = errorData.message || 'Failed to log game'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Log game error:', error)
|
||||
this.serverError = 'Network error occurred'
|
||||
} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
},
|
||||
|
||||
async handleUpdateGame() {
|
||||
this.editSubmitting = true
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
date: this.editingGame.date,
|
||||
commanderId: parseInt(this.editingGame.commanderId),
|
||||
playerCount: parseInt(this.editingGame.playerCount),
|
||||
rounds: parseInt(this.editingGame.rounds),
|
||||
won: this.editingGame.won === true || this.editingGame.won === 'true',
|
||||
startingPlayerWon:
|
||||
this.editingGame.startingPlayerWon === true ||
|
||||
this.editingGame.startingPlayerWon === 'true',
|
||||
solRingTurnOneWon:
|
||||
this.editingGame.solRingTurnOneWon === true ||
|
||||
this.editingGame.solRingTurnOneWon === 'true'
|
||||
}
|
||||
|
||||
// Only include notes if it's not empty
|
||||
if (this.editingGame.notes && this.editingGame.notes.trim()) {
|
||||
payload.notes = this.editingGame.notes
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/games/${this.editingGame.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const index = this.games.findIndex(
|
||||
(g) => g.id === this.editingGame.id
|
||||
)
|
||||
if (index !== -1) {
|
||||
this.games[index] = data.game
|
||||
}
|
||||
this.cancelEdit()
|
||||
await this.reloadStats()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
this.serverError = errorData.message || 'Failed to update game'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update game error:', error)
|
||||
this.serverError = 'Network error occurred'
|
||||
} finally {
|
||||
this.editSubmitting = false
|
||||
}
|
||||
},
|
||||
|
||||
editGame(gameId) {
|
||||
const game = this.games.find((g) => g.id === gameId)
|
||||
if (game) {
|
||||
// Convert date from MM/DD/YYYY to YYYY-MM-DD format for input type="date"
|
||||
let dateForInput = game.date
|
||||
if (dateForInput && dateForInput.includes('/')) {
|
||||
const [month, day, year] = dateForInput.split('/')
|
||||
dateForInput = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
this.editingGame = {
|
||||
id: game.id,
|
||||
date: dateForInput,
|
||||
commanderId: game.commanderId,
|
||||
playerCount: game.playerCount,
|
||||
won: game.won === 1 || game.won === true,
|
||||
rounds: game.rounds,
|
||||
startingPlayerWon:
|
||||
game.startingPlayerWon === 1 || game.startingPlayerWon === true,
|
||||
solRingTurnOneWon:
|
||||
game.solRingTurnOneWon === 1 || game.solRingTurnOneWon === true,
|
||||
notes: game.notes
|
||||
}
|
||||
this.showLogForm = true
|
||||
this.serverError = ''
|
||||
setTimeout(() => {
|
||||
document.querySelector('form')?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
|
||||
cancelEdit() {
|
||||
this.editingGame = null
|
||||
this.resetForm()
|
||||
this.showLogForm = false
|
||||
},
|
||||
|
||||
deleteGame(gameId) {
|
||||
this.deleteConfirm.gameId = gameId
|
||||
this.deleteConfirm.show = true
|
||||
},
|
||||
|
||||
async confirmDelete() {
|
||||
const gameId = this.deleteConfirm.gameId
|
||||
if (!gameId) return
|
||||
|
||||
this.deleteConfirm.deleting = true
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/games/${gameId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
this.games = this.games.filter((g) => g.id !== gameId)
|
||||
this.deleteConfirm.show = false
|
||||
this.deleteConfirm.gameId = null
|
||||
await this.reloadStats()
|
||||
} else {
|
||||
this.serverError = 'Failed to delete game'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete game error:', error)
|
||||
this.serverError = 'Network error occurred while deleting'
|
||||
} finally {
|
||||
this.deleteConfirm.deleting = false
|
||||
}
|
||||
},
|
||||
|
||||
resetForm() {
|
||||
this.newGame = {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
commanderId: '',
|
||||
playerCount: 4,
|
||||
won: false,
|
||||
rounds: 8,
|
||||
startingPlayerWon: false,
|
||||
solRingTurnOneWon: false,
|
||||
notes: ''
|
||||
}
|
||||
this.editingGame = null
|
||||
this.serverError = ''
|
||||
},
|
||||
|
||||
|
||||
|
||||
getCommanderName(id) {
|
||||
const commander = this.commanders.find((c) => c.id === id)
|
||||
return commander ? commander.name : 'Unknown Commander'
|
||||
},
|
||||
|
||||
formatDate(dateString) {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
},
|
||||
|
||||
async exportGames() {
|
||||
try {
|
||||
const token = localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')
|
||||
const response = await fetch('/api/games/export', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Export failed')
|
||||
}
|
||||
|
||||
// Generate filename with current date
|
||||
const today = new Date().toLocaleDateString('en-US').replace(/\//g, '_')
|
||||
const filename = `edh_games_${today}.json`
|
||||
|
||||
// Create blob and download
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error)
|
||||
// Show error message to user
|
||||
this.serverError = 'Failed to export games. Please try again.'
|
||||
setTimeout(() => {
|
||||
this.serverError = ''
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('gameManager', gameManager)
|
||||
})
|
||||
@@ -1,275 +0,0 @@
|
||||
// Profile management Alpine.js component
|
||||
function profileManager() {
|
||||
return {
|
||||
// Current user data
|
||||
currentUser: null,
|
||||
|
||||
// Navigation state
|
||||
mobileMenuOpen: false,
|
||||
|
||||
// Form data
|
||||
formData: {
|
||||
username: '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
},
|
||||
|
||||
// State
|
||||
submitting: {
|
||||
username: false,
|
||||
password: false,
|
||||
deleteAccount: false
|
||||
},
|
||||
errors: {},
|
||||
serverError: {
|
||||
username: '',
|
||||
password: '',
|
||||
deleteAccount: ''
|
||||
},
|
||||
successMessage: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
|
||||
// Delete account modal state
|
||||
showDeleteConfirm: false,
|
||||
deleteConfirmText: '',
|
||||
|
||||
// Lifecycle
|
||||
async init() {
|
||||
await this.loadCurrentUser()
|
||||
},
|
||||
|
||||
// Logout function
|
||||
logout() {
|
||||
localStorage.removeItem('edh-stats-token')
|
||||
sessionStorage.removeItem('edh-stats-token')
|
||||
window.location.href = '/login.html'
|
||||
},
|
||||
|
||||
// Load current user data
|
||||
async loadCurrentUser() {
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
this.currentUser = data.user
|
||||
this.formData.username = data.user.username
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load current user error:', error)
|
||||
}
|
||||
},
|
||||
|
||||
// Validation - Username
|
||||
validateUsername() {
|
||||
this.successMessage.username = ''
|
||||
if (!this.formData.username.trim()) {
|
||||
this.errors.username = 'Username is required'
|
||||
} else if (this.formData.username.length < 3) {
|
||||
this.errors.username = 'Username must be at least 3 characters'
|
||||
} else if (this.formData.username.length > 50) {
|
||||
this.errors.username = 'Username must be less than 50 characters'
|
||||
} else if (!/^[a-zA-Z0-9_-]+$/.test(this.formData.username)) {
|
||||
this.errors.username =
|
||||
'Username can only contain letters, numbers, underscores, and hyphens'
|
||||
} else if (this.formData.username === this.currentUser?.username) {
|
||||
this.errors.username = 'Username is the same as current username'
|
||||
} else {
|
||||
delete this.errors.username
|
||||
}
|
||||
},
|
||||
|
||||
// Validation - Current Password
|
||||
validateCurrentPassword() {
|
||||
this.serverError.password = ''
|
||||
if (!this.formData.currentPassword) {
|
||||
this.errors.currentPassword = 'Current password is required'
|
||||
} else {
|
||||
delete this.errors.currentPassword
|
||||
}
|
||||
},
|
||||
|
||||
// Validation - New Password
|
||||
validateNewPassword() {
|
||||
if (!this.formData.newPassword) {
|
||||
this.errors.newPassword = 'New password is required'
|
||||
} else if (this.formData.newPassword.length < 8) {
|
||||
this.errors.newPassword = 'Password must be at least 8 characters'
|
||||
} else if (this.formData.newPassword.length > 100) {
|
||||
this.errors.newPassword = 'Password must be less than 100 characters'
|
||||
} else if (!/(?=.*[a-z])/.test(this.formData.newPassword)) {
|
||||
this.errors.newPassword =
|
||||
'Password must contain at least one lowercase letter'
|
||||
} else if (!/(?=.*[A-Z])/.test(this.formData.newPassword)) {
|
||||
this.errors.newPassword =
|
||||
'Password must contain at least one uppercase letter'
|
||||
} else if (!/(?=.*\d)/.test(this.formData.newPassword)) {
|
||||
this.errors.newPassword = 'Password must contain at least one number'
|
||||
} else {
|
||||
delete this.errors.newPassword
|
||||
}
|
||||
},
|
||||
|
||||
// Validation - Confirm Password
|
||||
validateConfirmPassword() {
|
||||
if (!this.formData.confirmPassword) {
|
||||
this.errors.confirmPassword = 'Please confirm your password'
|
||||
} else if (this.formData.newPassword !== this.formData.confirmPassword) {
|
||||
this.errors.confirmPassword = 'Passwords do not match'
|
||||
} else {
|
||||
delete this.errors.confirmPassword
|
||||
}
|
||||
},
|
||||
|
||||
// Handle Update Username
|
||||
async handleUpdateUsername() {
|
||||
this.validateUsername()
|
||||
if (this.errors.username) return
|
||||
|
||||
this.submitting.username = true
|
||||
this.serverError.username = ''
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
const response = await fetch('/api/auth/update-username', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
newUsername: this.formData.username
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
this.successMessage.username = 'Username updated successfully!'
|
||||
this.currentUser.username = this.formData.username
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.successMessage.username = ''
|
||||
}, 3000)
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
this.serverError.username = errorData.message || 'Failed to update username'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update username error:', error)
|
||||
this.serverError.username = 'Network error occurred'
|
||||
} finally {
|
||||
this.submitting.username = false
|
||||
}
|
||||
},
|
||||
|
||||
// Handle Change Password
|
||||
async handleChangePassword() {
|
||||
this.validateCurrentPassword()
|
||||
this.validateNewPassword()
|
||||
this.validateConfirmPassword()
|
||||
|
||||
if (
|
||||
this.errors.currentPassword ||
|
||||
this.errors.newPassword ||
|
||||
this.errors.confirmPassword
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.submitting.password = true
|
||||
this.serverError.password = ''
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
const response = await fetch('/api/auth/change-password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
currentPassword: this.formData.currentPassword,
|
||||
newPassword: this.formData.newPassword
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
this.successMessage.password = 'Password changed successfully!'
|
||||
// Reset form
|
||||
this.formData.currentPassword = ''
|
||||
this.formData.newPassword = ''
|
||||
this.formData.confirmPassword = ''
|
||||
// Clear success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.successMessage.password = ''
|
||||
}, 3000)
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
this.serverError.password = errorData.message || 'Failed to change password'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error)
|
||||
this.serverError.password = 'Network error occurred'
|
||||
} finally {
|
||||
this.submitting.password = false
|
||||
}
|
||||
},
|
||||
|
||||
// Handle Delete Account
|
||||
async handleDeleteAccount() {
|
||||
// Extra safeguard - verify confirmation text
|
||||
if (this.deleteConfirmText !== 'delete my account') {
|
||||
this.serverError.deleteAccount = 'Confirmation text does not match'
|
||||
return
|
||||
}
|
||||
|
||||
this.submitting.deleteAccount = true
|
||||
this.serverError.deleteAccount = ''
|
||||
|
||||
try {
|
||||
const token =
|
||||
localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token')
|
||||
const response = await fetch('/api/auth/me', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Clear auth tokens
|
||||
localStorage.removeItem('edh-stats-token')
|
||||
sessionStorage.removeItem('edh-stats-token')
|
||||
|
||||
// Redirect to home page with success message
|
||||
window.location.href = '/login.html?deleted=true'
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
this.serverError.deleteAccount = errorData.message || 'Failed to delete account'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete account error:', error)
|
||||
this.serverError.deleteAccount = 'Network error occurred'
|
||||
} finally {
|
||||
this.submitting.deleteAccount = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register Alpine component
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('profileManager', profileManager)
|
||||
})
|
||||
@@ -1,211 +0,0 @@
|
||||
// Round Counter Alpine.js Component
|
||||
function roundCounterApp() {
|
||||
return {
|
||||
counterActive: false,
|
||||
currentRound: 1,
|
||||
startTime: null,
|
||||
elapsedTime: '00:00:00',
|
||||
avgTimePerRound: '00:00',
|
||||
timerInterval: null,
|
||||
hasPausedGame: false,
|
||||
pausedElapsedTime: 0, // Track elapsed time when paused
|
||||
|
||||
// Reset confirmation modal
|
||||
resetConfirm: {
|
||||
show: false,
|
||||
resetting: false
|
||||
},
|
||||
|
||||
async init() {
|
||||
// Load saved counter state
|
||||
this.loadCounter()
|
||||
|
||||
// Start timer if counter is active
|
||||
if (this.counterActive) {
|
||||
this.startTimer()
|
||||
}
|
||||
},
|
||||
|
||||
toggleCounter() {
|
||||
if (this.counterActive) {
|
||||
this.stopCounter()
|
||||
} else {
|
||||
this.startCounter()
|
||||
}
|
||||
},
|
||||
|
||||
startCounter() {
|
||||
this.counterActive = true
|
||||
this.hasPausedGame = false
|
||||
// Only set new start time if this isn't a resumed game
|
||||
if (!this.startTime) {
|
||||
this.startTime = new Date()
|
||||
} else {
|
||||
// Resuming: adjust start time to account for paused duration
|
||||
this.startTime = new Date(Date.now() - this.pausedElapsedTime * 1000)
|
||||
}
|
||||
this.saveCounter()
|
||||
this.startTimer()
|
||||
},
|
||||
|
||||
stopCounter() {
|
||||
this.counterActive = false
|
||||
this.hasPausedGame = true
|
||||
// Store the current elapsed time when pausing
|
||||
if (this.startTime) {
|
||||
this.pausedElapsedTime = Math.floor((Date.now() - new Date(this.startTime)) / 1000)
|
||||
}
|
||||
this.clearTimer()
|
||||
this.saveCounter()
|
||||
},
|
||||
|
||||
resetCounter() {
|
||||
this.counterActive = false
|
||||
this.hasPausedGame = false
|
||||
this.currentRound = 1
|
||||
this.startTime = null
|
||||
this.elapsedTime = '00:00:00'
|
||||
this.avgTimePerRound = '00:00'
|
||||
this.pausedElapsedTime = 0
|
||||
this.clearTimer()
|
||||
this.stopErrorChecking()
|
||||
this.saveCounter()
|
||||
},
|
||||
|
||||
stopErrorChecking() {
|
||||
// Prevent infinite error checking
|
||||
},
|
||||
|
||||
confirmReset() {
|
||||
this.resetConfirm.resetting = true
|
||||
|
||||
// Small delay to simulate work and show loading state
|
||||
setTimeout(() => {
|
||||
this.counterActive = false
|
||||
this.hasPausedGame = false
|
||||
this.currentRound = 1
|
||||
this.startTime = null
|
||||
this.elapsedTime = '00:00:00'
|
||||
this.avgTimePerRound = '00:00'
|
||||
this.pausedElapsedTime = 0
|
||||
this.clearTimer()
|
||||
this.saveCounter()
|
||||
this.resetConfirm.show = false
|
||||
this.resetConfirm.resetting = false
|
||||
}, 300)
|
||||
},
|
||||
|
||||
incrementRound() {
|
||||
this.currentRound++
|
||||
this.saveCounter()
|
||||
},
|
||||
|
||||
decrementRound() {
|
||||
if (this.currentRound > 1) {
|
||||
this.currentRound--
|
||||
this.saveCounter()
|
||||
}
|
||||
},
|
||||
|
||||
startTimer() {
|
||||
// Clear existing timer if any
|
||||
this.clearTimer()
|
||||
|
||||
this.timerInterval = setInterval(() => {
|
||||
if (this.counterActive && this.startTime) {
|
||||
const now = new Date()
|
||||
const elapsed = Math.floor((now - new Date(this.startTime)) / 1000)
|
||||
|
||||
const hours = Math.floor(elapsed / 3600)
|
||||
const minutes = Math.floor((elapsed % 3600) / 60)
|
||||
const seconds = elapsed % 60
|
||||
|
||||
this.elapsedTime = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
|
||||
// Calculate average time per round
|
||||
if (this.currentRound > 1 && elapsed > 0) {
|
||||
const avgSeconds = Math.floor(elapsed / this.currentRound)
|
||||
const avgMins = Math.floor(avgSeconds / 60)
|
||||
const avgSecs = avgSeconds % 60
|
||||
this.avgTimePerRound = `${avgMins}:${String(avgSecs).padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
clearTimer() {
|
||||
if (this.timerInterval) {
|
||||
clearInterval(this.timerInterval)
|
||||
this.timerInterval = null
|
||||
}
|
||||
},
|
||||
|
||||
saveCounter() {
|
||||
this.stopErrorChecking()
|
||||
localStorage.setItem(
|
||||
'edh-round-counter-state',
|
||||
JSON.stringify({
|
||||
counterActive: this.counterActive,
|
||||
currentRound: this.currentRound,
|
||||
startTime: this.startTime,
|
||||
elapsedTime: this.elapsedTime,
|
||||
avgTimePerRound: this.avgTimePerRound,
|
||||
hasPausedGame: this.hasPausedGame,
|
||||
pausedElapsedTime: this.pausedElapsedTime
|
||||
})
|
||||
)
|
||||
},
|
||||
|
||||
loadCounter() {
|
||||
this.stopErrorChecking()
|
||||
const saved = localStorage.getItem('edh-round-counter-state')
|
||||
if (saved) {
|
||||
try {
|
||||
const data = JSON.parse(saved)
|
||||
this.counterActive = data.counterActive || false
|
||||
this.currentRound = data.currentRound || 1
|
||||
this.startTime = data.startTime ? new Date(data.startTime) : null
|
||||
this.elapsedTime = data.elapsedTime || '00:00:00'
|
||||
this.avgTimePerRound = data.avgTimePerRound || '00:00'
|
||||
this.hasPausedGame = data.hasPausedGame || false
|
||||
this.pausedElapsedTime = data.pausedElapsedTime || 0
|
||||
this.startTimer()
|
||||
} catch (error) {
|
||||
console.error('Error loading counter:', error)
|
||||
this.resetCounter()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
saveAndGoToGameLog() {
|
||||
// Save the complete game data to localStorage for the game log page
|
||||
const now = new Date()
|
||||
localStorage.setItem(
|
||||
'edh-prefill-game',
|
||||
JSON.stringify({
|
||||
date: now.toISOString().split('T')[0], // YYYY-MM-DD format for date input
|
||||
rounds: this.currentRound,
|
||||
duration: this.elapsedTime,
|
||||
startTime: this.startTime
|
||||
? new Date(this.startTime).toISOString()
|
||||
: null,
|
||||
endTime: now.toISOString(),
|
||||
avgTimePerRound: this.avgTimePerRound
|
||||
})
|
||||
)
|
||||
|
||||
// Redirect to game log page
|
||||
window.location.href = '/games.html'
|
||||
},
|
||||
|
||||
// Utility
|
||||
destroy() {
|
||||
this.clearTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the Alpine component
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('roundCounterApp', roundCounterApp)
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
// Stats cards loader - dynamically loads stats-cards.html partial into pages
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const container = document.getElementById('stats-cards-container');
|
||||
|
||||
// Skip if there's no container element
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch and insert stats cards
|
||||
fetch('/stats-cards.html')
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load stats cards: ${response.statusText}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then((html) => {
|
||||
container.innerHTML = html;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error loading stats cards:', error);
|
||||
});
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
// Statistics management Alpine.js component
|
||||
function statsManager() {
|
||||
return {
|
||||
loading: true,
|
||||
stats: {
|
||||
totalGames: 0,
|
||||
winRate: 0,
|
||||
totalCommanders: 0,
|
||||
avgRounds: 0
|
||||
},
|
||||
commanderStats: [],
|
||||
charts: {},
|
||||
|
||||
async init() {
|
||||
await this.loadStats()
|
||||
},
|
||||
|
||||
async loadStats() {
|
||||
this.loading = true
|
||||
try {
|
||||
// Load overview stats
|
||||
const overviewResponse = await fetch('/api/stats/overview', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (overviewResponse.ok) {
|
||||
this.stats = await overviewResponse.json()
|
||||
}
|
||||
|
||||
// Load commander detailed stats
|
||||
const detailsResponse = await fetch('/api/stats/commanders', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (detailsResponse.ok) {
|
||||
const data = await detailsResponse.json()
|
||||
this.commanderStats = data.stats || []
|
||||
|
||||
// Initialize charts after data load
|
||||
this.$nextTick(() => {
|
||||
this.initCharts(data.charts)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load stats error:', error)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
initCharts(chartData) {
|
||||
// Destroy existing charts if any
|
||||
if (this.charts.colorWinRate) this.charts.colorWinRate.destroy()
|
||||
if (this.charts.playerCount) this.charts.playerCount.destroy()
|
||||
|
||||
// Pastel color palette (15 colors) - mixed to avoid similar colors adjacent
|
||||
const pastelColors = [
|
||||
'#FFD93D', // Pastel Yellow
|
||||
'#D4A5FF', // Pastel Violet
|
||||
'#FF9E9E', // Pastel Rose
|
||||
'#B4E7FF', // Pastel Cyan
|
||||
'#FFA94D', // Pastel Orange
|
||||
'#9D84B7', // Pastel Purple
|
||||
'#FF85B3', // Pastel Pink
|
||||
'#4D96FF', // Pastel Blue
|
||||
'#FFCB69', // Pastel Peach
|
||||
'#56AB91', // Pastel Teal
|
||||
'#FF6B6B', // Pastel Red
|
||||
'#FFB3D9', // Pastel Magenta
|
||||
'#A8E6CF', // Pastel Mint
|
||||
'#6BCB77', // Pastel Green
|
||||
'#C7CEEA' // Pastel Lavender
|
||||
]
|
||||
|
||||
// Filter out color combinations with no win rate
|
||||
const colorLabels = chartData?.colors?.labels || []
|
||||
const colorData = chartData?.colors?.data || []
|
||||
const filteredIndices = colorData
|
||||
.map((value, index) => (value > 0 ? index : -1))
|
||||
.filter((index) => index !== -1)
|
||||
|
||||
const filteredLabels = filteredIndices.map((i) => colorLabels[i])
|
||||
const filteredData = filteredIndices.map((i) => colorData[i])
|
||||
const filteredColors = filteredIndices.map(
|
||||
(_, index) => pastelColors[index % pastelColors.length]
|
||||
)
|
||||
|
||||
// Color Identity Win Rate Chart
|
||||
const colorCtx = document
|
||||
.getElementById('colorWinRateChart')
|
||||
.getContext('2d')
|
||||
this.charts.colorWinRate = new Chart(colorCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: filteredLabels,
|
||||
datasets: [
|
||||
{
|
||||
data: filteredData,
|
||||
backgroundColor: filteredColors,
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Player Count Win Rate Chart - filter out player counts with no wins
|
||||
const playerLabels = chartData?.playerCounts?.labels || []
|
||||
const playerData = chartData?.playerCounts?.data || []
|
||||
const playerFilteredIndices = playerData
|
||||
.map((value, index) => (value > 0 ? index : -1))
|
||||
.filter((index) => index !== -1)
|
||||
|
||||
const playerFilteredLabels = playerFilteredIndices.map((i) => playerLabels[i])
|
||||
const playerFilteredData = playerFilteredIndices.map((i) => playerData[i])
|
||||
|
||||
const playerCtx = document
|
||||
.getElementById('playerCountChart')
|
||||
.getContext('2d')
|
||||
this.charts.playerCount = new Chart(playerCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: playerFilteredLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Win Rate (%)',
|
||||
data: playerFilteredData,
|
||||
backgroundColor: '#6366f1',
|
||||
borderRadius: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
calculatePercentage(value, total) {
|
||||
if (!total) return 0
|
||||
return Math.round((value / total) * 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('statsManager', statsManager)
|
||||
})
|
||||
@@ -1,309 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Login - EDH Stats Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Login to track your Magic: The Gathering EDH/Commander games"
|
||||
/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body
|
||||
class="min-h-full flex flex-col py-12 px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
<div class="flex items-center justify-center flex-1">
|
||||
<div class="max-w-md w-full space-y-8" x-data="loginWithRegistration()">
|
||||
<!-- Header -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold font-mtg text-edh-primary mb-2">
|
||||
EDH Stats
|
||||
</h1>
|
||||
<h2 class="text-xl text-gray-600">Sign in to your account</h2>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div class="card">
|
||||
<form class="space-y-6" @submit.prevent="handleLogin">
|
||||
<!-- Username Field -->
|
||||
<div>
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
x-model="formData.username"
|
||||
@input="validateUsername()"
|
||||
:class="errors.username ? 'border-red-500 focus:ring-red-500' : ''"
|
||||
class="form-input pl-10"
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
x-show="errors.username"
|
||||
x-text="errors.username"
|
||||
class="form-error"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div>
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
required
|
||||
x-model="formData.password"
|
||||
@input="validatePassword()"
|
||||
:class="errors.password ? 'border-red-500 focus:ring-red-500' : ''"
|
||||
class="form-input pl-10 pr-10"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
<svg
|
||||
x-show="!showPassword"
|
||||
class="h-5 w-5 text-gray-400 hover:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
x-show="showPassword"
|
||||
class="h-5 w-5 text-gray-400 hover:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
x-show="errors.password"
|
||||
x-text="errors.password"
|
||||
class="form-error"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Remember Me -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="remember"
|
||||
name="remember"
|
||||
type="checkbox"
|
||||
x-model="formData.remember"
|
||||
class="h-4 w-4 text-edh-accent focus:ring-edh-accent border-gray-300 rounded"
|
||||
/>
|
||||
<label for="remember" class="ml-2 block text-sm text-gray-900">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="text-sm">
|
||||
<a
|
||||
href="#"
|
||||
class="font-medium text-edh-accent hover:text-edh-primary"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
x-show="serverError"
|
||||
x-transition
|
||||
class="rounded-md bg-red-50 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-red-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-red-800" x-text="serverError"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary w-full flex justify-center items-center space-x-2"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': loading }"
|
||||
>
|
||||
<svg
|
||||
x-show="!loading"
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
x-show="loading"
|
||||
class="animate-spin h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span x-text="loading ? 'Signing in...' : 'Sign in'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Register Link -->
|
||||
<div class="text-center" x-show="allowRegistration">
|
||||
<p class="text-sm text-gray-600">
|
||||
Don't have an account?
|
||||
<a
|
||||
href="/register.html"
|
||||
class="font-medium text-edh-accent hover:text-edh-primary"
|
||||
>
|
||||
Sign up
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Scripts -->
|
||||
<script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/auth-check.js"></script>
|
||||
<script>
|
||||
function loginWithRegistration() {
|
||||
return {
|
||||
...loginForm(),
|
||||
allowRegistration: true,
|
||||
|
||||
async init() {
|
||||
// Check registration config
|
||||
await this.checkRegistrationConfig()
|
||||
// Call parent init if it exists
|
||||
if (typeof super.init === 'function') {
|
||||
super.init()
|
||||
}
|
||||
},
|
||||
|
||||
async checkRegistrationConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/auth/config')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
this.allowRegistration = data.allowRegistration
|
||||
} else {
|
||||
this.allowRegistration = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check registration config:', error)
|
||||
this.allowRegistration = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,422 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Profile - EDH Stats Tracker</title>
|
||||
<meta name="description" content="Edit your profile and account settings" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="h-full flex flex-col" x-data="profileManager()">
|
||||
<!-- Navigation Header -->
|
||||
<header class="bg-slate-900 text-white shadow-lg">
|
||||
<nav class="container mx-auto px-4 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold font-mtg">EDH Stats</h1>
|
||||
<div class="hidden md:flex space-x-6">
|
||||
<a
|
||||
href="/dashboard.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>Dashboard</a
|
||||
>
|
||||
<a
|
||||
href="/commanders.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>Commanders</a
|
||||
>
|
||||
<a
|
||||
href="/games.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>Game Log</a
|
||||
>
|
||||
<a
|
||||
href="/stats.html"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>Statistics</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- User Menu -->
|
||||
<div class="relative" x-data="{ userMenuOpen: false }">
|
||||
<button
|
||||
@click="userMenuOpen = !userMenuOpen"
|
||||
class="flex items-center space-x-2 hover:text-edh-accent"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
></path>
|
||||
</svg>
|
||||
<span x-text="currentUser?.username"></span>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="userMenuOpen"
|
||||
@click.away="userMenuOpen = false"
|
||||
x-transition
|
||||
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50"
|
||||
>
|
||||
<a
|
||||
href="/profile.html"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>Profile</a
|
||||
>
|
||||
<hr class="my-1" />
|
||||
<button
|
||||
@click="logout()"
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button @click="mobileMenuOpen = !mobileMenuOpen" class="md:hidden">
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div
|
||||
x-show="mobileMenuOpen"
|
||||
x-transition
|
||||
class="md:hidden mt-4 pt-4 border-t border-edh-secondary"
|
||||
>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<a
|
||||
href="/dashboard.html"
|
||||
class="text-white hover:text-edh-accent transition-colors py-2"
|
||||
>Dashboard</a
|
||||
>
|
||||
<a
|
||||
href="/commanders.html"
|
||||
class="text-white hover:text-edh-accent transition-colors py-2"
|
||||
>Commanders</a
|
||||
>
|
||||
<a
|
||||
href="/games.html"
|
||||
class="text-white hover:text-edh-accent transition-colors py-2"
|
||||
>Log Game</a
|
||||
>
|
||||
<a
|
||||
href="/stats.html"
|
||||
class="text-white hover:text-edh-accent transition-colors py-2"
|
||||
>Statistics</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 container mx-auto px-4 py-8 max-w-2xl">
|
||||
<div class="mb-8">
|
||||
<a href="/dashboard.html" class="text-edh-accent hover:text-edh-primary"
|
||||
>← Back to Dashboard</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl font-bold mb-8">Profile Settings</h1>
|
||||
|
||||
<!-- Edit Username Section -->
|
||||
<div class="card mb-8">
|
||||
<h2 class="text-2xl font-semibold mb-6">Edit Username</h2>
|
||||
|
||||
<form @submit.prevent="handleUpdateUsername">
|
||||
<div class="mb-6">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
x-model="formData.username"
|
||||
@input="validateUsername()"
|
||||
:class="errors.username ? 'border-red-500 focus:ring-red-500' : ''"
|
||||
class="form-input"
|
||||
placeholder="Enter your new username"
|
||||
/>
|
||||
<p
|
||||
x-show="errors.username"
|
||||
x-text="errors.username"
|
||||
class="form-error"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="successMessage.username"
|
||||
class="mb-6 p-4 bg-green-50 rounded-lg border border-green-200"
|
||||
>
|
||||
<p class="text-green-800" x-text="successMessage.username"></p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="serverError.username"
|
||||
class="mb-6 p-4 bg-red-50 rounded-lg border border-red-200"
|
||||
>
|
||||
<p class="text-red-800" x-text="serverError.username"></p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting.username"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<span x-show="!submitting.username">Update Username</span>
|
||||
<span x-show="submitting.username">Updating...</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Section -->
|
||||
<div class="card">
|
||||
<h2 class="text-2xl font-semibold mb-6">Change Password</h2>
|
||||
|
||||
<form @submit.prevent="handleChangePassword">
|
||||
<!-- Current Password -->
|
||||
<div class="mb-6">
|
||||
<label for="currentPassword" class="form-label"
|
||||
>Current Password</label
|
||||
>
|
||||
<input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
x-model="formData.currentPassword"
|
||||
@input="validateCurrentPassword()"
|
||||
:class="errors.currentPassword ? 'border-red-500 focus:ring-red-500' : ''"
|
||||
class="form-input"
|
||||
placeholder="Enter your current password"
|
||||
/>
|
||||
<p
|
||||
x-show="errors.currentPassword"
|
||||
x-text="errors.currentPassword"
|
||||
class="form-error"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- New Password -->
|
||||
<div class="mb-6">
|
||||
<label for="newPassword" class="form-label">New Password</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
x-model="formData.newPassword"
|
||||
@input="validateNewPassword()"
|
||||
:class="errors.newPassword ? 'border-red-500 focus:ring-red-500' : ''"
|
||||
class="form-input"
|
||||
placeholder="Enter your new password"
|
||||
/>
|
||||
<p
|
||||
x-show="errors.newPassword"
|
||||
x-text="errors.newPassword"
|
||||
class="form-error"
|
||||
></p>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Password must be at least 8 characters and contain uppercase,
|
||||
lowercase, and numbers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm New Password -->
|
||||
<div class="mb-6">
|
||||
<label for="confirmPassword" class="form-label"
|
||||
>Confirm New Password</label
|
||||
>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
x-model="formData.confirmPassword"
|
||||
@input="validateConfirmPassword()"
|
||||
:class="errors.confirmPassword ? 'border-red-500 focus:ring-red-500' : ''"
|
||||
class="form-input"
|
||||
placeholder="Confirm your new password"
|
||||
/>
|
||||
<p
|
||||
x-show="errors.confirmPassword"
|
||||
x-text="errors.confirmPassword"
|
||||
class="form-error"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="successMessage.password"
|
||||
class="mb-6 p-4 bg-green-50 rounded-lg border border-green-200"
|
||||
>
|
||||
<p class="text-green-800" x-text="successMessage.password"></p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="serverError.password"
|
||||
class="mb-6 p-4 bg-red-50 rounded-lg border border-red-200"
|
||||
>
|
||||
<p class="text-red-800" x-text="serverError.password"></p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting.password"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<span x-show="!submitting.password">Change Password</span>
|
||||
<span x-show="submitting.password">Updating...</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Delete Account Section -->
|
||||
<div class="card mt-8 border-red-200 bg-red-50">
|
||||
<h2 class="text-2xl font-semibold mb-4 text-red-900">Danger Zone</h2>
|
||||
|
||||
<div class="mb-6 p-4 bg-red-100 rounded-lg border border-red-300">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-red-600 mr-3 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-semibold text-red-900">Delete Account</h3>
|
||||
<p class="text-sm text-red-800 mt-1">
|
||||
This action is permanent and cannot be undone. All your game records, commanders, and statistics will be deleted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="showDeleteConfirm = true"
|
||||
type="button"
|
||||
class="btn bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Delete Account Confirmation Modal -->
|
||||
<div
|
||||
x-show="showDeleteConfirm"
|
||||
x-transition
|
||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||
@click.self="showDeleteConfirm = false"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div class="flex items-center justify-center w-10 h-10 mx-auto bg-red-100 rounded-full mb-4">
|
||||
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4v2m0 5v-6m0-6V3m0 0h3m-3 0H9" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-gray-900 text-center mb-4">
|
||||
Delete Account?
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-600 text-center mb-4">
|
||||
Are you absolutely sure? This will permanently delete:
|
||||
</p>
|
||||
|
||||
<ul class="text-sm text-gray-600 space-y-2 mb-6 pl-4">
|
||||
<li class="flex items-start">
|
||||
<span class="text-red-600 mr-2">•</span>
|
||||
<span>Your account and all personal data</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-red-600 mr-2">•</span>
|
||||
<span>All game records and statistics</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="text-red-600 mr-2">•</span>
|
||||
<span>All commanders and associated data</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form @submit.prevent="handleDeleteAccount">
|
||||
<div class="mb-4">
|
||||
<label for="deleteConfirmText" class="form-label text-center block">
|
||||
Type <span class="font-semibold">delete my account</span> to confirm:
|
||||
</label>
|
||||
<input
|
||||
id="deleteConfirmText"
|
||||
type="text"
|
||||
x-model="deleteConfirmText"
|
||||
placeholder="delete my account"
|
||||
class="form-input mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="serverError.deleteAccount"
|
||||
class="mb-4 p-3 bg-red-50 rounded-lg border border-red-200"
|
||||
>
|
||||
<p class="text-red-800 text-sm" x-text="serverError.deleteAccount"></p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="showDeleteConfirm = false; deleteConfirmText = ''"
|
||||
class="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="deleteConfirmText !== 'delete my account' || submitting.deleteAccount"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': deleteConfirmText !== 'delete my account' || submitting.deleteAccount }"
|
||||
class="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium disabled:hover:bg-red-600"
|
||||
>
|
||||
<span x-show="!submitting.deleteAccount">Delete Account</span>
|
||||
<span x-show="submitting.deleteAccount">Deleting...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script src="/js/auth-guard.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
<script src="/js/profile.js"></script>
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,725 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Register - EDH Stats Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Create a new account to track your Magic: The Gathering EDH/Commander games"
|
||||
/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="min-h-full flex flex-col py-12 px-4 sm:px-6 lg:px-8" x-data="{ showTosModal: false }">
|
||||
<div class="flex items-center justify-center flex-1">
|
||||
<div
|
||||
class="max-w-md w-full space-y-8"
|
||||
x-data="registerForm()"
|
||||
x-init="init()"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold font-mtg text-edh-primary mb-2">
|
||||
EDH Stats
|
||||
</h1>
|
||||
<h2
|
||||
class="text-xl text-gray-600"
|
||||
x-text="allowRegistration ? 'Create your account' : 'Registration Disabled'"
|
||||
></h2>
|
||||
</div>
|
||||
|
||||
<!-- Registration Disabled Message -->
|
||||
<div
|
||||
x-show="!allowRegistration"
|
||||
x-transition
|
||||
class="card bg-yellow-50 border-yellow-200"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-yellow-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-yellow-800">
|
||||
User registration is currently disabled. Please contact an
|
||||
administrator if you need to create an account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
If you already have an account,
|
||||
<a
|
||||
href="/login.html"
|
||||
class="font-medium text-edh-accent hover:text-edh-primary"
|
||||
>
|
||||
sign in here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Register Form -->
|
||||
<div class="card" x-show="allowRegistration">
|
||||
<form class="space-y-6" @submit.prevent="handleRegister">
|
||||
<!-- Username Field -->
|
||||
<div>
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
x-model="formData.username"
|
||||
@input="validateUsername()"
|
||||
:class="errors.username ? 'border-red-500 focus:ring-red-500' : ''"
|
||||
class="form-input pl-10"
|
||||
placeholder="Choose a username (3+ characters)"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
x-show="errors.username"
|
||||
x-text="errors.username"
|
||||
class="form-error"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Email Field -->
|
||||
<div>
|
||||
<label for="email" class="form-label"
|
||||
>Email Address (Optional)</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
x-model="formData.email"
|
||||
@input="validateEmail()"
|
||||
:class="errors.email ? 'border-red-500 focus:ring-red-500' : ''"
|
||||
class="form-input pl-10"
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
x-show="errors.email"
|
||||
x-text="errors.email"
|
||||
class="form-error"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div>
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
required
|
||||
x-model="formData.password"
|
||||
@input="validatePassword()"
|
||||
:class="errors.password ? 'border-red-500 focus:ring-red-500' : ''"
|
||||
class="form-input pl-10 pr-10"
|
||||
placeholder="Minimum 8 characters"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
<svg
|
||||
x-show="!showPassword"
|
||||
class="h-5 w-5 text-gray-400 hover:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
x-show="showPassword"
|
||||
class="h-5 w-5 text-gray-400 hover:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
x-show="errors.password"
|
||||
x-text="errors.password"
|
||||
class="form-error"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password Field -->
|
||||
<div>
|
||||
<label for="confirmPassword" class="form-label"
|
||||
>Confirm Password</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
required
|
||||
x-model="formData.confirmPassword"
|
||||
@input="validateConfirmPassword()"
|
||||
:class="errors.confirmPassword ? 'border-red-500 focus:ring-red-500' : ''"
|
||||
class="form-input pl-10 pr-10"
|
||||
placeholder="Re-enter your password"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
<svg
|
||||
x-show="!showConfirmPassword"
|
||||
class="h-5 w-5 text-gray-400 hover:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
x-show="showConfirmPassword"
|
||||
class="h-5 w-5 text-gray-400 hover:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
x-show="errors.confirmPassword"
|
||||
x-text="errors.confirmPassword"
|
||||
class="form-error"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Terms & Conditions -->
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="terms"
|
||||
name="terms"
|
||||
type="checkbox"
|
||||
x-model="formData.terms"
|
||||
@change="validateTerms()"
|
||||
:class="errors.terms ? 'border-red-500' : ''"
|
||||
class="h-4 w-4 text-edh-accent focus:ring-edh-accent border-gray-300 rounded"
|
||||
/>
|
||||
<label for="terms" class="ml-2 block text-sm text-gray-900">
|
||||
I agree to the
|
||||
<button
|
||||
type="button"
|
||||
@click="showTosModal = true"
|
||||
class="text-edh-accent hover:text-edh-primary font-medium"
|
||||
>
|
||||
Terms of Service
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
x-show="errors.terms"
|
||||
x-text="errors.terms"
|
||||
class="form-error"
|
||||
></p>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
x-show="serverError"
|
||||
x-transition
|
||||
class="rounded-md bg-red-50 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-red-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-red-800" x-text="serverError"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div
|
||||
x-show="successMessage"
|
||||
x-transition
|
||||
class="rounded-md bg-green-50 p-4"
|
||||
>
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-green-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-green-800" x-text="successMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary w-full flex justify-center items-center space-x-2"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': loading }"
|
||||
>
|
||||
<svg
|
||||
x-show="!loading"
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
x-show="loading"
|
||||
class="animate-spin h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span
|
||||
x-text="loading ? 'Creating account...' : 'Create account'"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Login Link -->
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">
|
||||
Already have an account?
|
||||
<a
|
||||
href="/login.html"
|
||||
class="font-medium text-edh-accent hover:text-edh-primary"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Password Requirements Info -->
|
||||
<div class="card bg-blue-50 border-blue-200" x-show="allowRegistration">
|
||||
<h3 class="text-sm font-medium text-blue-800 mb-2">
|
||||
Password Requirements
|
||||
</h3>
|
||||
<div class="text-xs text-blue-700 space-y-1">
|
||||
<p
|
||||
class="flex items-center"
|
||||
:class="{ 'text-green-700': formData.password.length >= 8 }"
|
||||
>
|
||||
<svg
|
||||
:class="{ 'text-green-500': formData.password.length >= 8 }"
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
At least 8 characters
|
||||
</p>
|
||||
<p
|
||||
class="flex items-center"
|
||||
:class="{ 'text-green-700': /(?=.*[a-z])/.test(formData.password) }"
|
||||
>
|
||||
<svg
|
||||
:class="{ 'text-green-500': /(?=.*[a-z])/.test(formData.password) }"
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
At least one lowercase letter
|
||||
</p>
|
||||
<p
|
||||
class="flex items-center"
|
||||
:class="{ 'text-green-700': /(?=.*[A-Z])/.test(formData.password) }"
|
||||
>
|
||||
<svg
|
||||
:class="{ 'text-green-500': /(?=.*[A-Z])/.test(formData.password) }"
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
At least one uppercase letter
|
||||
</p>
|
||||
<p
|
||||
class="flex items-center"
|
||||
:class="{ 'text-green-700': /(?=.*\d)/.test(formData.password) }"
|
||||
>
|
||||
<svg
|
||||
:class="{ 'text-green-500': /(?=.*\d)/.test(formData.password) }"
|
||||
class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
At least one number (0-9)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terms of Service Modal -->
|
||||
<template x-if="showTosModal">
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 z-40" @click="showTosModal = false"></div>
|
||||
</template>
|
||||
<template x-if="showTosModal">
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full h-[90vh] flex flex-col lg:w-1/2 sm:w-11/12">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex-shrink-0 bg-white border-b border-gray-200 p-6 flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold font-mtg text-edh-primary">
|
||||
Terms of Service
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@click="showTosModal = false"
|
||||
class="text-gray-400 hover:text-gray-600 flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content - Scrollable -->
|
||||
<div class="flex-1 overflow-y-auto p-6 text-gray-700">
|
||||
<p class="text-gray-600 mb-6 text-sm">Last updated: January 2026</p>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
Welcome to EDH Stats Tracker
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
By creating an account and using EDH Stats Tracker, you agree to these Terms of Service.
|
||||
We've kept them simple and straightforward—no legal jargon that makes your brain hurt. (You're welcome.)
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
1. What This Service Is
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
EDH Stats Tracker is a web application designed to help Magic: The Gathering players track,
|
||||
analyze, and celebrate their EDH/Commander game statistics. We store your game records,
|
||||
commanders, and associated statistics. Think of us as your personal game journal that actually does math for you.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
2. User Accounts
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
You are responsible for maintaining the confidentiality of your password. You agree not to share your account
|
||||
credentials with anyone else. If someone logs into your account and logs all your games as losses, we'll sympathize,
|
||||
but that's on you.
|
||||
</p>
|
||||
<p class="text-gray-700 mb-4">
|
||||
You represent that the information you provide during registration is accurate and true.
|
||||
If you use a fake name, that's between you and Magic's lore team.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
3. Your Content
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
All game records, commander lists, notes, and data you enter into EDH Stats Tracker remain your property.
|
||||
We don't own your stats—we just help you organize them. We won't sell your data, trade it for pack equity, or share it with strangers.
|
||||
(We're not monsters.)
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
4. Acceptable Use
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
You agree to use EDH Stats Tracker for its intended purpose: tracking and analyzing your EDH games.
|
||||
Don't use it to harass, deceive, or cause harm to others. Be cool.
|
||||
</p>
|
||||
<p class="text-gray-700 mb-4">
|
||||
Don't try to break the service through hacking, automated attacks, or other malicious means.
|
||||
If you find a security vulnerability, please let us know responsibly instead.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
5. Service Availability
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
We aim to keep EDH Stats Tracker available and reliable. However, like all software,
|
||||
it may occasionally go down for maintenance or experience technical issues. We're doing our best here.
|
||||
</p>
|
||||
<p class="text-gray-700 mb-4">
|
||||
We reserve the right to make changes to the service, add features, or modify functionality
|
||||
as we see fit. We'll try to keep breaking changes to a minimum.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
6. Limitation of Liability
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
EDH Stats Tracker is provided "as is." While we work hard to make it great,
|
||||
we don't guarantee it will be perfect or meet every need. We're not liable for data loss,
|
||||
lost wins, or your opponent's lucky top-decks.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
7. Changes to Terms
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
We may update these Terms of Service from time to time. We'll let you know about significant changes.
|
||||
Your continued use of the service after changes means you accept the new terms.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
8. Account Termination
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
You can delete your account at any time. Your data will be removed from our systems
|
||||
in accordance with our privacy practices. If you violate these terms, we may disable your account.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
9. Questions?
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
If you have questions about these terms, please reach out. We're reasonable people
|
||||
(at least we think so).
|
||||
</p>
|
||||
|
||||
<div class="mt-8 p-6 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p class="text-blue-900 text-sm">
|
||||
<strong>TL;DR:</strong> Use the service as intended, keep your password safe, it's your responsibility.
|
||||
We'll keep your data private and try to keep the service running. Don't be a jerk. That's it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex-shrink-0 bg-gray-50 border-t border-gray-200 p-6 flex justify-between items-center">
|
||||
<button
|
||||
type="button"
|
||||
@click="showTosModal = false"
|
||||
class="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="document.getElementById('terms').checked = true; showTosModal = false"
|
||||
class="px-4 py-2 text-white bg-edh-accent rounded hover:bg-edh-primary"
|
||||
>
|
||||
I Agree
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script src="/js/auth-check.js"></script>
|
||||
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,217 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Round Counter - EDH Stats Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Live round counter for Magic: The Gathering EDH/Commander games"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="h-full flex flex-col" x-data="roundCounterApp()">
|
||||
<!-- Navigation Header -->
|
||||
<header class="bg-slate-900 text-white shadow-lg">
|
||||
<nav class="container mx-auto px-4 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold font-mtg">EDH Stats</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/dashboard.html" class="text-white hover:text-edh-accent">
|
||||
← Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 container mx-auto px-4 py-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Title Section -->
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-4xl font-bold mb-2">Round Counter</h2>
|
||||
<p class="text-gray-600">Track rounds in your current game</p>
|
||||
</div>
|
||||
|
||||
<!-- Counter Display -->
|
||||
<div class="card mb-8">
|
||||
<!-- Game Status -->
|
||||
<div class="text-center mb-8">
|
||||
<span
|
||||
x-show="!counterActive && !hasPausedGame"
|
||||
class="inline-block px-4 py-2 bg-gray-200 text-gray-800 rounded-full text-sm font-semibold"
|
||||
>
|
||||
Ready to Start
|
||||
</span>
|
||||
<span
|
||||
x-show="!counterActive && hasPausedGame"
|
||||
class="inline-block px-4 py-2 bg-yellow-200 text-yellow-800 rounded-full text-sm font-semibold"
|
||||
>
|
||||
Game Paused
|
||||
</span>
|
||||
<span
|
||||
x-show="counterActive"
|
||||
class="inline-block px-4 py-2 bg-green-200 text-green-800 rounded-full text-sm font-semibold"
|
||||
>
|
||||
Game in Progress
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Large Round Display -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="text-8xl font-bold text-edh-primary mb-4">
|
||||
<span x-text="currentRound"></span>
|
||||
</div>
|
||||
<p class="text-2xl text-gray-600">Current Round</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Round Increment/Decrement -->
|
||||
<div class="flex flex-wrap gap-4 justify-center mb-12">
|
||||
<!-- Decrement -->
|
||||
<button
|
||||
@click="decrementRound()"
|
||||
:disabled="currentRound <= 1 || !counterActive"
|
||||
class="btn bg-red-500 hover:bg-red-600 text-white px-8 py-6 text-4xl font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-lg"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
|
||||
<!-- Increment -->
|
||||
<button
|
||||
@click="incrementRound()"
|
||||
:disabled="!counterActive"
|
||||
class="btn bg-green-500 hover:bg-green-600 text-white px-8 py-6 text-4xl font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-lg"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Elapsed Time -->
|
||||
<div
|
||||
x-show="counterActive"
|
||||
class="text-center mb-8 p-4 bg-blue-50 rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-gray-600 mb-2">Elapsed Time</p>
|
||||
<p
|
||||
class="text-3xl font-bold text-edh-primary"
|
||||
x-text="elapsedTime"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="flex flex-wrap gap-4 justify-center mb-8">
|
||||
<!-- Start/Stop Button -->
|
||||
<button
|
||||
@click="toggleCounter()"
|
||||
:class="counterActive ? 'bg-red-500 hover:bg-red-600' : 'bg-green-500 hover:bg-green-600'"
|
||||
class="btn text-white px-8 py-4 text-lg font-bold transition-colors"
|
||||
>
|
||||
<span x-show="!counterActive && !hasPausedGame">Start Game</span>
|
||||
<span x-show="!counterActive && hasPausedGame">Resume Game</span>
|
||||
<span x-show="counterActive">Pause Game</span>
|
||||
</button>
|
||||
|
||||
<!-- Reset Button -->
|
||||
<button
|
||||
@click="resetCounter()"
|
||||
:disabled="counterActive || (!hasPausedGame && !counterActive)"
|
||||
class="btn bg-gray-500 hover:bg-gray-600 text-white px-8 py-4 text-lg font-bold disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Game Stats Card -->
|
||||
<div class="card">
|
||||
<h3 class="text-xl font-semibold mb-4">Game Statistics</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p class="text-sm text-gray-600 mb-1">Current Round</p>
|
||||
<p
|
||||
class="text-2xl font-bold text-edh-primary"
|
||||
x-text="currentRound"
|
||||
></p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p class="text-sm text-gray-600 mb-1">Time Elapsed</p>
|
||||
<p
|
||||
class="text-2xl font-bold text-edh-primary"
|
||||
x-text="elapsedTime"
|
||||
></p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p class="text-sm text-gray-600 mb-1">Avg Time/Round</p>
|
||||
<p
|
||||
class="text-2xl font-bold text-edh-primary"
|
||||
x-text="avgTimePerRound"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Game Button -->
|
||||
<div x-show="counterActive || hasPausedGame" class="mt-8 text-center">
|
||||
<button
|
||||
@click="saveAndGoToGameLog()"
|
||||
class="btn btn-primary text-white px-8 py-4 text-lg font-bold"
|
||||
>
|
||||
End Game & Log Results
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Reset Confirmation Modal -->
|
||||
<div
|
||||
x-show="resetConfirm.show"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
@click.self="resetConfirm.show = false"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-lg max-w-sm w-full">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">
|
||||
Reset Counter
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Are you sure you want to reset the counter? This will lose all
|
||||
progress.
|
||||
</p>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="resetConfirm.show = false"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="confirmReset()"
|
||||
:disabled="resetConfirm.resetting"
|
||||
class="btn btn-primary bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<span x-show="!resetConfirm.resetting">Reset Counter</span>
|
||||
<span x-show="resetConfirm.resetting">Resetting...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script src="/js/auth-guard.js"></script>
|
||||
<script src="/js/round-counter.js"></script>
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,92 +0,0 @@
|
||||
<!-- Stats Cards Component -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-white/80 text-sm">Total Games</p>
|
||||
<p class="text-3xl font-bold" x-text="stats.totalGames || 0"></p>
|
||||
</div>
|
||||
<svg
|
||||
class="w-8 h-8 text-white/60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-white/80 text-sm">Win Rate</p>
|
||||
<p class="text-3xl font-bold">
|
||||
<span x-text="stats.winRate || 0"></span>%
|
||||
</p>
|
||||
</div>
|
||||
<svg
|
||||
class="w-8 h-8 text-white/60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-white/80 text-sm">Active Decks</p>
|
||||
<p class="text-3xl font-bold" x-text="stats.totalCommanders || 0"></p>
|
||||
</div>
|
||||
<svg
|
||||
class="w-8 h-8 text-white/60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-white/80 text-sm">Avg Rounds</p>
|
||||
<p class="text-3xl font-bold" x-text="stats.avgRounds || 0"></p>
|
||||
</div>
|
||||
<svg
|
||||
class="w-8 h-8 text-white/60"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,182 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Statistics - EDH Stats Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="View detailed statistics for your EDH/Commander games"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="h-full" x-data="statsManager()">
|
||||
<!-- Navigation Header -->
|
||||
<header class="bg-slate-900 text-white shadow-lg">
|
||||
<nav class="container mx-auto px-4 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold font-mtg">Statistics</h1>
|
||||
<div class="flex space-x-4">
|
||||
<a
|
||||
href="/dashboard.html"
|
||||
class="hover:text-edh-accent transition-colors"
|
||||
>
|
||||
← Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- Stats Filters (Future Phase) -->
|
||||
<!-- <div class="mb-8 card">
|
||||
<h3 class="font-semibold mb-4">Filters</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<select class="form-select w-auto">
|
||||
<option>All Time</option>
|
||||
<option>Last 30 Days</option>
|
||||
<option>Last Year</option>
|
||||
</select>
|
||||
<select class="form-select w-auto">
|
||||
<option>All Commanders</option>
|
||||
<option>Color: White</option>
|
||||
<option>Color: Blue</option>
|
||||
<option>Color: Black</option>
|
||||
<option>Color: Red</option>
|
||||
<option>Color: Green</option>
|
||||
</select>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- Overview Cards (loaded via stats-cards-loader.js) -->
|
||||
<div id="stats-cards-container"></div>
|
||||
|
||||
<!-- Charts Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
<!-- Win Rate by Color -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold mb-6">Win Rate by Color Identity</h3>
|
||||
<div class="h-64 relative">
|
||||
<canvas id="colorWinRateChart"></canvas>
|
||||
<div
|
||||
x-show="loading"
|
||||
class="absolute inset-0 flex items-center justify-center bg-white/50"
|
||||
>
|
||||
<div class="loading-spinner w-8 h-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Games per Month (Future) -->
|
||||
<!-- <div class="card">
|
||||
<h3 class="text-lg font-semibold mb-6">Games Played History</h3>
|
||||
<div class="h-64 relative">
|
||||
<canvas id="gamesHistoryChart"></canvas>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- Win Rate by Player Count -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold mb-6">Win Rate by Player Count</h3>
|
||||
<div class="h-64 relative">
|
||||
<canvas id="playerCountChart"></canvas>
|
||||
<div
|
||||
x-show="loading"
|
||||
class="absolute inset-0 flex items-center justify-center bg-white/50"
|
||||
>
|
||||
<div class="loading-spinner w-8 h-8"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Stats Table -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold mb-6">Commander Performance</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead
|
||||
class="bg-gray-50 text-gray-600 text-xs uppercase font-semibold"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-6 py-3">Commander</th>
|
||||
<th class="px-6 py-3 text-center">Games</th>
|
||||
<th class="px-6 py-3 text-center">Win Rate</th>
|
||||
<th class="px-6 py-3 text-center">Avg Rounds</th>
|
||||
<th class="px-6 py-3 text-center">Starting Win %</th>
|
||||
<th class="px-6 py-3 text-center">Sol Ring Win %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<template x-for="stat in commanderStats" :key="stat.commanderId">
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div
|
||||
class="font-medium text-gray-900"
|
||||
x-text="stat.name"
|
||||
></div>
|
||||
<div class="flex space-x-1 mt-1">
|
||||
<template x-for="color in stat.colors">
|
||||
<div
|
||||
class="w-3 h-3 rounded-sm"
|
||||
:class="'color-' + color.toLowerCase()"
|
||||
:title="color"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 text-center"
|
||||
x-text="stat.totalGames"
|
||||
></td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span
|
||||
class="px-2 py-1 rounded text-xs font-semibold"
|
||||
:class="stat.winRate >= 25 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'"
|
||||
x-text="stat.winRate + '%'"
|
||||
></span>
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 text-center"
|
||||
x-text="Math.round(stat.avgRounds)"
|
||||
></td>
|
||||
<td
|
||||
class="px-6 py-4 text-center text-sm text-gray-500"
|
||||
x-text="calculatePercentage(stat.startingPlayerWins, stat.totalGames) + '%'"
|
||||
></td>
|
||||
<td
|
||||
class="px-6 py-4 text-center text-sm text-gray-500"
|
||||
x-text="calculatePercentage(stat.solRingWins, stat.totalGames) + '%'"
|
||||
></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="commanderStats.length === 0">
|
||||
<td colspan="6" class="px-6 py-8 text-center text-gray-500">
|
||||
No data available yet.
|
||||
<a href="/games.html" class="text-edh-primary hover:underline"
|
||||
>Log some more games!</a
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<script src="/js/auth-guard.js"></script>
|
||||
<script src="/js/stats-cards-loader.js"></script>
|
||||
<script src="/js/stats.js"></script>
|
||||
<script src="/js/footer-loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
2.1.10
|
||||
52
frontend/src/app.css
Normal file
@@ -0,0 +1,52 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom fonts */
|
||||
@font-face {
|
||||
font-family: 'Beleren';
|
||||
src: url('/fonts/Beleren-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Custom utility classes */
|
||||
.font-mtg {
|
||||
font-family: 'Beleren', serif;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-indigo-600 text-white hover:bg-indigo-700 active:bg-indigo-800;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-800 hover:bg-gray-300 active:bg-gray-400;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700 active:bg-red-800;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-md p-6;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 3px solid #f3f4f6;
|
||||
border-top: 3px solid #6366f1;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
12
frontend/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full bg-gray-50">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body class="h-full" data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
83
frontend/src/lib/components/CommanderListSection.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script>
|
||||
export let commanders = [];
|
||||
export let archived = false;
|
||||
export let getColorIcons;
|
||||
export let formatDate;
|
||||
export let onEdit = () => {};
|
||||
export let onDelete = () => {};
|
||||
</script>
|
||||
|
||||
{#if commanders.length > 0}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{#each commanders as commander}
|
||||
<div
|
||||
class="card hover:shadow-lg transition-shadow {archived ? 'opacity-80' : ''}"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-xl font-bold text-gray-900">{commander.name}</h3>
|
||||
{#if archived}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-700"
|
||||
>
|
||||
Archived
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
on:click={() => onEdit(commander)}
|
||||
class="text-indigo-600 hover:text-indigo-800 text-xl font-medium opacity-100"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
on:click={() => onDelete(commander)}
|
||||
class="text-red-600 hover:text-red-800 text-xl font-medium opacity-100"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="color-pill">
|
||||
{#each getColorIcons(commander.colors) as icon}
|
||||
<img
|
||||
src={icon.src}
|
||||
alt={`${icon.id} color icon`}
|
||||
class="color-icon"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-gray-900">
|
||||
{commander.totalGames || 0}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Games Played</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-gray-900">
|
||||
{Number(commander.winRate || 0).toFixed(1)}%
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Win Rate</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-gray-900">
|
||||
{Number(commander.avgRounds || 0).toFixed(1)}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 mt-1">Avg Rounds</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-sm text-gray-500 mt-2">Added</div>
|
||||
<div class="text-sm text-gray-700">{formatDate(commander.createdAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
26
frontend/src/lib/components/Footer.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let version = "";
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const response = await fetch("/version.txt");
|
||||
if (response.ok) {
|
||||
version = (await response.text()).trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load version:", error);
|
||||
version = "unknown";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<footer class="bg-white border-t border-gray-200 mt-12 w-full">
|
||||
<div class="container mx-auto px-4 py-6 text-center text-sm text-gray-600">
|
||||
<p>EDH Stats • Track your Commander games</p>
|
||||
{#if version}
|
||||
<p class="text-xs text-gray-500 mt-1">v{version}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</footer>
|
||||
170
frontend/src/lib/components/NavBar.svelte
Normal file
@@ -0,0 +1,170 @@
|
||||
<script>
|
||||
import { auth, currentUser } from "$stores/auth";
|
||||
|
||||
let userMenuOpen = false;
|
||||
let mobileMenuOpen = false;
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout();
|
||||
}
|
||||
|
||||
function closeMenus() {
|
||||
userMenuOpen = false;
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="bg-slate-900 text-white shadow-lg">
|
||||
<nav class="container mx-auto px-4 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="text-2xl font-bold font-mtg hover:text-edh-accent"
|
||||
>
|
||||
EDH Stats
|
||||
</a>
|
||||
<div class="hidden md:flex space-x-6">
|
||||
<a
|
||||
href="/commanders"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>
|
||||
Commanders
|
||||
</a>
|
||||
<a
|
||||
href="/round-counter"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>
|
||||
Round timer
|
||||
</a>
|
||||
<a
|
||||
href="/games"
|
||||
class="text-white hover:text-edh-accent transition-colors"
|
||||
>
|
||||
Game Log
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- User Menu -->
|
||||
<div class="relative">
|
||||
<button
|
||||
on:click|stopPropagation={() => (userMenuOpen = !userMenuOpen)}
|
||||
class="flex items-center space-x-2 hover:text-edh-accent"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>{$currentUser?.username || "User"}</span>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if userMenuOpen}
|
||||
<ul
|
||||
role="menu"
|
||||
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50"
|
||||
>
|
||||
<li role="none">
|
||||
<a
|
||||
href="/profile"
|
||||
role="menuitem"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
on:click|stopPropagation={closeMenus}
|
||||
>
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
<li role="none"><hr class="my-1" /></li>
|
||||
<li role="none">
|
||||
<button
|
||||
role="menuitem"
|
||||
on:click|stopPropagation={handleLogout}
|
||||
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
on:click={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
class="md:hidden"
|
||||
aria-label="Toggle navigation menu"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
{#if mobileMenuOpen}
|
||||
<div class="md:hidden mt-4 pb-4 space-y-2">
|
||||
<a
|
||||
href="/commanders"
|
||||
class="block py-2 hover:text-edh-accent transition-colors"
|
||||
on:click={closeMenus}
|
||||
>
|
||||
Commanders
|
||||
</a>
|
||||
<a
|
||||
href="/round-counter"
|
||||
class="block py-2 hover:text-edh-accent transition-colors"
|
||||
on:click={closeMenus}
|
||||
>
|
||||
Round timer
|
||||
</a>
|
||||
<a
|
||||
href="/games"
|
||||
class="block py-2 hover:text-edh-accent transition-colors"
|
||||
on:click={closeMenus}
|
||||
>
|
||||
Game Log
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<svelte:window
|
||||
on:click={() => {
|
||||
if (userMenuOpen) userMenuOpen = false;
|
||||
}}
|
||||
/>
|
||||
28
frontend/src/lib/components/ProtectedRoute.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { auth, isAuthenticated } from "$stores/auth";
|
||||
|
||||
let loading = true;
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = auth.subscribe(($auth) => {
|
||||
loading = $auth.loading;
|
||||
|
||||
if (!$auth.loading && !$auth.token) {
|
||||
// Not authenticated, redirect to login
|
||||
goto("/login");
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<div class="loading-spinner w-12 h-12"></div>
|
||||
</div>
|
||||
{:else if $isAuthenticated}
|
||||
<slot />
|
||||
{/if}
|
||||
185
frontend/src/lib/stores/auth.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import { writable, derived } from 'svelte/store'
|
||||
import { browser } from '$app/environment'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
const initialState = {
|
||||
token: null,
|
||||
user: null,
|
||||
loading: true,
|
||||
allowRegistration: true
|
||||
}
|
||||
|
||||
function createAuthStore() {
|
||||
const { subscribe, update } = writable(initialState)
|
||||
|
||||
const markAuthenticated = (user) => {
|
||||
update((state) => ({ ...state, token: 'cookie', user }))
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
init: async () => {
|
||||
if (!browser) return
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/session', {
|
||||
credentials: 'include',
|
||||
cache: 'no-store'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
update((state) => ({ ...state, token: null, user: null, loading: false }))
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.authenticated) {
|
||||
update((state) => ({
|
||||
...state,
|
||||
token: 'cookie',
|
||||
user: data.user,
|
||||
loading: false
|
||||
}))
|
||||
} else {
|
||||
update((state) => ({ ...state, token: null, user: null, loading: false }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth init error:', error)
|
||||
update((state) => ({ ...state, loading: false }))
|
||||
}
|
||||
},
|
||||
login: async (username, password, remember = false) => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password, remember })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
markAuthenticated(data.user)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || 'Login failed'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: 'Network error. Please try again.'
|
||||
}
|
||||
}
|
||||
},
|
||||
register: async (username, email, password) => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
email: email || undefined,
|
||||
password
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
markAuthenticated(data.user)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
let errorMessage = data.message || 'Registration failed'
|
||||
if (data.details && Array.isArray(data.details)) {
|
||||
errorMessage = data.details.join(', ')
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: 'Network error. Please try again.'
|
||||
}
|
||||
}
|
||||
},
|
||||
logout: async () => {
|
||||
if (browser) {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
update((state) => ({ ...state, token: null, user: null, loading: false }))
|
||||
goto('/login')
|
||||
},
|
||||
updateUser: (user) => {
|
||||
update((state) => ({ ...state, user }))
|
||||
},
|
||||
checkRegistrationConfig: async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/config', {
|
||||
credentials: 'include'
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
update((state) => ({ ...state, allowRegistration: data.allowRegistration }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check registration config:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = createAuthStore()
|
||||
|
||||
export const isAuthenticated = derived(auth, ($auth) => !!$auth.token && !!$auth.user)
|
||||
|
||||
export const currentUser = derived(auth, ($auth) => $auth.user)
|
||||
|
||||
export async function authenticatedFetch(url, options = {}) {
|
||||
const shouldSetJsonHeader =
|
||||
options.body !== undefined &&
|
||||
(typeof options.body === 'string' || options.body instanceof String)
|
||||
|
||||
const defaultHeaders = {
|
||||
...(shouldSetJsonHeader && { 'Content-Type': 'application/json' })
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
await auth.logout()
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
2
frontend/src/routes/+layout.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
12
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { auth } from '$stores/auth';
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
auth.checkRegistrationConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
162
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,162 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { auth } from "$stores/auth";
|
||||
|
||||
let allowRegistration = true;
|
||||
let activeSlide = 0;
|
||||
let slideshowInterval;
|
||||
|
||||
const slides = [
|
||||
{ src: "/images/commanders.png", alt: "Commanders management screenshot" },
|
||||
{ src: "/images/logs.png", alt: "Game log screenshot" },
|
||||
{ src: "/images/stats.png", alt: "Statistics dashboard screenshot" },
|
||||
{ src: "/images/timer.png", alt: "Round timer screenshot" },
|
||||
];
|
||||
|
||||
function nextSlide() {
|
||||
activeSlide = (activeSlide + 1) % slides.length;
|
||||
}
|
||||
|
||||
function goToSlide(index) {
|
||||
const newIndex = (index + slides.length) % slides.length;
|
||||
activeSlide = newIndex;
|
||||
resetInterval();
|
||||
}
|
||||
|
||||
function resetInterval() {
|
||||
clearInterval(slideshowInterval);
|
||||
slideshowInterval = setInterval(nextSlide, 6000);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
(async () => {
|
||||
await auth.checkRegistrationConfig();
|
||||
})();
|
||||
|
||||
const unsubscribe = auth.subscribe(($auth) => {
|
||||
allowRegistration = $auth.allowRegistration;
|
||||
});
|
||||
|
||||
slideshowInterval = setInterval(nextSlide, 6000);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearInterval(slideshowInterval);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>EDH Stats Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Track your Magic: The Gathering EDH/Commander games and statistics"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-full flex flex-col py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-center flex-1">
|
||||
<div class="w-full space-y-8 text-center">
|
||||
<div class="max-w-md mx-auto">
|
||||
<h1 class="text-4xl font-bold font-mtg text-edh-primary mb-4">
|
||||
EDH Stats
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 mb-8">
|
||||
Track your Commander games and statistics
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<a href="/login" class="btn btn-primary w-full block"
|
||||
>Login to Track Games</a
|
||||
>
|
||||
{#if allowRegistration}
|
||||
<a href="/register" class="btn btn-secondary w-full block"
|
||||
>Create New Account</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slideshow -->
|
||||
<div class="mt-12 max-w-4xl mx-auto">
|
||||
<div class="carousel">
|
||||
{#each slides as slide, index}
|
||||
<figure
|
||||
class="carousel-slide {index === activeSlide ? 'is-active' : ''}"
|
||||
>
|
||||
<img src={slide.src} alt={slide.alt} loading="lazy" />
|
||||
</figure>
|
||||
{/each}
|
||||
<div class="carousel-dots">
|
||||
{#each slides as _, dotIndex}
|
||||
<button
|
||||
class:active-dot={dotIndex === activeSlide}
|
||||
aria-label={`Go to slide ${dotIndex + 1}`}
|
||||
on:click={() => goToSlide(dotIndex)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.carousel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.15);
|
||||
max-width: 70%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 700ms ease;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.carousel-slide.is-active {
|
||||
opacity: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carousel-slide img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.carousel-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.carousel-dots button {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.carousel-dots button.active-dot {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
636
frontend/src/routes/commanders/+page.svelte
Normal file
@@ -0,0 +1,636 @@
|
||||
<script>
|
||||
import { onMount, tick } from "svelte";
|
||||
import { authenticatedFetch } from "$stores/auth";
|
||||
import NavBar from "$components/NavBar.svelte";
|
||||
import ProtectedRoute from "$components/ProtectedRoute.svelte";
|
||||
import Footer from "$components/Footer.svelte";
|
||||
import CommanderListSection from "$components/CommanderListSection.svelte";
|
||||
|
||||
let showAddForm = false;
|
||||
let commanders = [];
|
||||
let loading = false;
|
||||
let loadingMore = false;
|
||||
let submitting = false;
|
||||
let serverError = "";
|
||||
let editingCommander = null;
|
||||
let formElement;
|
||||
let limit = 20;
|
||||
let offset = 0;
|
||||
let hasMore = false;
|
||||
|
||||
let newCommander = {
|
||||
name: "",
|
||||
colors: [],
|
||||
archived: false,
|
||||
};
|
||||
|
||||
$: formData = editingCommander || newCommander;
|
||||
$: activeCommanders = commanders.filter((cmd) => !cmd.archived);
|
||||
$: archivedCommanders = commanders.filter((cmd) => cmd.archived);
|
||||
|
||||
const mtgColors = [
|
||||
{ id: "W", name: "White", hex: "#F0E6D2" },
|
||||
{ id: "U", name: "Blue", hex: "#0E68AB" },
|
||||
{ id: "B", name: "Black", hex: "#2C2B2D" },
|
||||
{ id: "R", name: "Red", hex: "#C44536" },
|
||||
{ id: "G", name: "Green", hex: "#5A7A3B" },
|
||||
];
|
||||
|
||||
const colorIconMap = {
|
||||
W: "/images/W.png",
|
||||
U: "/images/U.png",
|
||||
B: "/images/B.png",
|
||||
R: "/images/R.png",
|
||||
G: "/images/G.png",
|
||||
C: "/images/C.png",
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await loadCommanders();
|
||||
});
|
||||
|
||||
async function loadCommanders({ append = false } = {}) {
|
||||
if (append) {
|
||||
if (loadingMore || !hasMore) return;
|
||||
loadingMore = true;
|
||||
} else {
|
||||
loading = true;
|
||||
offset = 0;
|
||||
hasMore = false;
|
||||
}
|
||||
try {
|
||||
// Load commanders with pagination
|
||||
const queryOffset = append ? offset : 0;
|
||||
const params = new URLSearchParams({
|
||||
limit: limit.toString(),
|
||||
offset: queryOffset.toString(),
|
||||
});
|
||||
const response = await authenticatedFetch(
|
||||
`/api/commanders?${params.toString()}`,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const commandersList = data.commanders || [];
|
||||
|
||||
const mappedCommanders = commandersList.map((cmd) => ({
|
||||
...cmd,
|
||||
commanderId: cmd.id,
|
||||
totalGames: cmd.totalGames || 0,
|
||||
winRate: cmd.winRate || 0,
|
||||
avgRounds: cmd.avgRounds || 0,
|
||||
wins: cmd.totalWins || 0,
|
||||
archived: cmd.archived ?? false,
|
||||
}));
|
||||
|
||||
if (append) {
|
||||
const existingIds = new Set(
|
||||
commanders.map((cmd) => cmd.id || cmd.commanderId),
|
||||
);
|
||||
const deduped = mappedCommanders.filter(
|
||||
(cmd) => !existingIds.has(cmd.id || cmd.commanderId),
|
||||
);
|
||||
commanders = [...commanders, ...deduped];
|
||||
} else {
|
||||
commanders = mappedCommanders;
|
||||
}
|
||||
|
||||
hasMore = data.pagination?.hasMore ?? false;
|
||||
offset = queryOffset + commandersList.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Load commanders error:", error);
|
||||
serverError = "Failed to load commanders";
|
||||
} finally {
|
||||
if (append) {
|
||||
loadingMore = false;
|
||||
} else {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleColor(colorId) {
|
||||
const current = editingCommander || newCommander;
|
||||
if (current.colors.includes(colorId)) {
|
||||
current.colors = current.colors.filter((c) => c !== colorId);
|
||||
} else {
|
||||
current.colors = [...current.colors, colorId];
|
||||
}
|
||||
|
||||
if (editingCommander) {
|
||||
editingCommander = { ...editingCommander, colors: current.colors };
|
||||
} else {
|
||||
newCommander = { ...newCommander, colors: current.colors };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeColorsInput(colors) {
|
||||
if (!colors) return [];
|
||||
if (Array.isArray(colors)) return colors;
|
||||
if (typeof colors === "string") {
|
||||
return colors.split("").map((c) => c.toUpperCase());
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function startEdit(commander) {
|
||||
// Handle both array and string formats for colors
|
||||
const colorsArray = normalizeColorsInput(commander.colors);
|
||||
|
||||
editingCommander = {
|
||||
id: commander.id || commander.commanderId,
|
||||
name: commander.name,
|
||||
colors: colorsArray,
|
||||
archived: commander.archived ?? false,
|
||||
};
|
||||
showAddForm = true;
|
||||
serverError = "";
|
||||
|
||||
await tick();
|
||||
formElement?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingCommander = null;
|
||||
showAddForm = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function handleAddCommander(e) {
|
||||
e.preventDefault();
|
||||
serverError = "";
|
||||
|
||||
const current = editingCommander || newCommander;
|
||||
|
||||
if (!current.name.trim()) {
|
||||
serverError = "Commander name is required";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting = true;
|
||||
try {
|
||||
if (editingCommander) {
|
||||
// Update existing commander
|
||||
const response = await authenticatedFetch(
|
||||
`/api/commanders/${editingCommander.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
name: current.name.trim(),
|
||||
colors: current.colors,
|
||||
archived: current.archived ?? false,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
await loadCommanders();
|
||||
editingCommander = null;
|
||||
resetForm();
|
||||
showAddForm = false;
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
serverError = errorData.message || "Failed to update commander";
|
||||
}
|
||||
} else {
|
||||
// Create new commander
|
||||
const response = await authenticatedFetch("/api/commanders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: current.name.trim(),
|
||||
colors: current.colors,
|
||||
archived: current.archived ?? false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadCommanders();
|
||||
resetForm();
|
||||
showAddForm = false;
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
serverError = errorData.message || "Failed to add commander";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Commander save error:", error);
|
||||
serverError = "Network error occurred";
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
newCommander = {
|
||||
name: "",
|
||||
colors: [],
|
||||
archived: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getColorIcons(colors) {
|
||||
const normalized = normalizeColorsInput(colors);
|
||||
const list = normalized.length > 0 ? normalized : ["C"];
|
||||
|
||||
return list
|
||||
.map((colorId) => ({
|
||||
id: colorId,
|
||||
src: colorIconMap[colorId] || null,
|
||||
}))
|
||||
.filter((icon) => Boolean(icon.src));
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return "N/A";
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return "N/A";
|
||||
}
|
||||
}
|
||||
|
||||
let deleteConfirm = {
|
||||
show: false,
|
||||
commanderId: null,
|
||||
commanderName: "",
|
||||
deleting: false,
|
||||
};
|
||||
|
||||
function showDeleteConfirm(commanderId, commanderName) {
|
||||
deleteConfirm = {
|
||||
show: true,
|
||||
commanderId,
|
||||
commanderName,
|
||||
deleting: false,
|
||||
};
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
if (!deleteConfirm.deleting) {
|
||||
deleteConfirm.show = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleOverlayClick(event) {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeDeleteDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteDialogKeydown(event) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
closeDeleteDialog();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
deleteConfirm.deleting = true;
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`/api/commanders/${deleteConfirm.commanderId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
await loadCommanders();
|
||||
deleteConfirm.show = false;
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
serverError = errorData.message || "Failed to delete commander";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Delete commander error:", error);
|
||||
serverError = "Network error occurred";
|
||||
} finally {
|
||||
deleteConfirm.deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreCommanders() {
|
||||
await loadCommanders({ append: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Commanders - EDH Stats Tracker</title>
|
||||
<meta name="description" content="Manage your EDH/Commander decks" />
|
||||
</svelte:head>
|
||||
|
||||
<ProtectedRoute>
|
||||
<div class="min-h-screen bg-gray-50 flex flex-col">
|
||||
<NavBar />
|
||||
|
||||
<main class="container mx-auto px-4 py-8 flex-1">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Commanders</h1>
|
||||
<button
|
||||
on:click={() => {
|
||||
if (showAddForm) {
|
||||
cancelEdit();
|
||||
} else {
|
||||
showAddForm = true;
|
||||
}
|
||||
}}
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{#if showAddForm}
|
||||
Cancel
|
||||
{:else}
|
||||
Add Commander
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Commander Form -->
|
||||
{#if showAddForm}
|
||||
<div class="card mb-8">
|
||||
<h2 class="text-xl font-bold mb-4">
|
||||
{editingCommander ? "Edit Commander" : "Add New Commander"}
|
||||
</h2>
|
||||
|
||||
<form
|
||||
on:submit={handleAddCommander}
|
||||
class="space-y-4"
|
||||
bind:this={formElement}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Commander Name *
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={formData.name}
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Enter commander name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Color Identity
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
{#each mtgColors as color}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => toggleColor(color.id)}
|
||||
class="color-chip-button {formData.colors.includes(color.id)
|
||||
? 'border-gray-900 ring-2 ring-offset-2 ring-gray-900'
|
||||
: 'border-gray-300 hover:border-gray-400'}"
|
||||
title={color.name}
|
||||
>
|
||||
<img
|
||||
src={colorIconMap[color.id]}
|
||||
alt=""
|
||||
class="color-chip-icon"
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span class="sr-only">{color.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Leave empty for colorless commanders
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if editingCommander}
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-3">
|
||||
<label class="flex items-start gap-3 text-sm text-gray-900">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={formData.archived}
|
||||
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span>
|
||||
Archive this commander
|
||||
<span class="block text-xs text-gray-500">
|
||||
Archived commanders stay visible in this list but cannot
|
||||
be selected when logging games.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if serverError}
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<p class="text-sm font-medium text-red-800">{serverError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="btn btn-primary disabled:opacity-50 flex-1"
|
||||
>
|
||||
{#if submitting}
|
||||
<div class="loading-spinner w-5 h-5 mx-auto"></div>
|
||||
{:else}
|
||||
{editingCommander ? "Update Commander" : "Add Commander"}
|
||||
{/if}
|
||||
</button>
|
||||
{#if editingCommander}
|
||||
<button
|
||||
type="button"
|
||||
on:click={cancelEdit}
|
||||
disabled={submitting}
|
||||
class="btn bg-gray-200 hover:bg-gray-300 text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Commanders List -->
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="loading-spinner w-12 h-12"></div>
|
||||
</div>
|
||||
{:else if commanders.length === 0}
|
||||
<div class="card text-center py-12">
|
||||
<p class="text-gray-600 mb-4">No commanders yet</p>
|
||||
<button on:click={() => (showAddForm = true)} class="btn btn-primary">
|
||||
Add Your First Commander
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-12">
|
||||
{#if activeCommanders.length > 0}
|
||||
<CommanderListSection
|
||||
commanders={activeCommanders}
|
||||
{getColorIcons}
|
||||
{formatDate}
|
||||
onEdit={startEdit}
|
||||
onDelete={(commander) =>
|
||||
showDeleteConfirm(
|
||||
commander.id || commander.commanderId,
|
||||
commander.name,
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if archivedCommanders.length > 0}
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="rounded-md border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800"
|
||||
>
|
||||
Archived commanders remain in your history but will not appear
|
||||
when logging new games.
|
||||
</div>
|
||||
<CommanderListSection
|
||||
commanders={archivedCommanders}
|
||||
archived={true}
|
||||
{getColorIcons}
|
||||
{formatDate}
|
||||
onEdit={startEdit}
|
||||
onDelete={(commander) =>
|
||||
showDeleteConfirm(
|
||||
commander.id || commander.commanderId,
|
||||
commander.name,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasMore}
|
||||
<div class="flex justify-center pt-6">
|
||||
<button
|
||||
on:click={loadMoreCommanders}
|
||||
class="btn btn-secondary"
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{#if loadingMore}
|
||||
<div class="loading-spinner w-5 h-5"></div>
|
||||
{:else}
|
||||
Load More
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{:else if hasMore && commanders.length > 0}
|
||||
<p class="text-center text-gray-500 pt-6">
|
||||
You have reached the end of the line.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if deleteConfirm.show}
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50"
|
||||
on:click={handleOverlayClick}
|
||||
on:keydown={handleDeleteDialogKeydown}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg p-6 max-w-sm w-full mx-4"
|
||||
role="document"
|
||||
>
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">
|
||||
Delete Commander
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Are you sure you want to delete "{deleteConfirm.commanderName}"?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<button
|
||||
on:click={() => (deleteConfirm.show = false)}
|
||||
disabled={deleteConfirm.deleting}
|
||||
class="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
on:click={handleDelete}
|
||||
disabled={deleteConfirm.deleting}
|
||||
class="px-4 py-2 text-white bg-red-600 rounded-md hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{#if deleteConfirm.deleting}
|
||||
Deleting...
|
||||
{:else}
|
||||
Delete
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
|
||||
<style>
|
||||
:global(.color-icon) {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
background-color: #fff;
|
||||
padding: 0.2rem;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:global(.color-pill) {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 9999px;
|
||||
padding: 0.3rem 0.6rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.color-chip-icon {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
border-radius: 9999px;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
background-color: #fff;
|
||||
/*border: 1px solid #e5e7eb;*/
|
||||
padding: 0.2rem;
|
||||
/*box-shadow: inset 0 1px 1px rgba(15, 23, 42, 0.12);*/
|
||||
}
|
||||
|
||||
.color-chip-button {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 9999px;
|
||||
border-width: 2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f3f4f6;
|
||||
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
392
frontend/src/routes/dashboard/+page.svelte
Normal file
@@ -0,0 +1,392 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { authenticatedFetch } from "$stores/auth";
|
||||
import NavBar from "$components/NavBar.svelte";
|
||||
import ProtectedRoute from "$components/ProtectedRoute.svelte";
|
||||
import Footer from "$components/Footer.svelte";
|
||||
|
||||
let Chart;
|
||||
let stats = {
|
||||
totalGames: 0,
|
||||
winRate: 0,
|
||||
totalCommanders: 0,
|
||||
inactiveCommanders: 0,
|
||||
avgRounds: 0,
|
||||
};
|
||||
|
||||
let recentGames = [];
|
||||
let topCommanders = [];
|
||||
let loading = true;
|
||||
let charts = {};
|
||||
|
||||
onMount(async () => {
|
||||
if (browser) {
|
||||
// Dynamically import Chart.js
|
||||
const ChartModule = await import("chart.js/auto");
|
||||
Chart = ChartModule.default;
|
||||
}
|
||||
await loadDashboardData();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Clean up charts
|
||||
if (charts.colorWinRate) charts.colorWinRate.destroy();
|
||||
if (charts.playerCount) charts.playerCount.destroy();
|
||||
});
|
||||
|
||||
async function loadDashboardData() {
|
||||
loading = true;
|
||||
try {
|
||||
// Load user stats
|
||||
const statsResponse = await authenticatedFetch("/api/stats/overview");
|
||||
if (statsResponse.ok) {
|
||||
stats = await statsResponse.json();
|
||||
}
|
||||
|
||||
// Load recent games
|
||||
const gamesResponse = await authenticatedFetch("/api/games?limit=5");
|
||||
if (gamesResponse.ok) {
|
||||
const gamesData = await gamesResponse.json();
|
||||
recentGames = gamesData.games || [];
|
||||
}
|
||||
|
||||
// Load top commanders and chart data
|
||||
const commandersResponse = await authenticatedFetch(
|
||||
"/api/stats/commanders",
|
||||
);
|
||||
if (commandersResponse.ok) {
|
||||
const commandersData = await commandersResponse.json();
|
||||
const commanders = Array.isArray(commandersData.stats)
|
||||
? commandersData.stats
|
||||
: [];
|
||||
topCommanders = commanders.slice(0, 5);
|
||||
|
||||
// Initialize charts after data is loaded
|
||||
setTimeout(() => initCharts(commandersData.charts), 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load dashboard data:", error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function initCharts(chartData) {
|
||||
if (!browser || !Chart) return;
|
||||
|
||||
// Destroy existing charts if any
|
||||
if (charts.colorWinRate) charts.colorWinRate.destroy();
|
||||
if (charts.playerCount) charts.playerCount.destroy();
|
||||
|
||||
// Pastel color palette (15 colors)
|
||||
const pastelColors = [
|
||||
"#FFD93D",
|
||||
"#D4A5FF",
|
||||
"#FF9E9E",
|
||||
"#B4E7FF",
|
||||
"#FFA94D",
|
||||
"#9D84B7",
|
||||
"#FF85B3",
|
||||
"#4D96FF",
|
||||
"#FFCB69",
|
||||
"#56AB91",
|
||||
"#FF6B6B",
|
||||
"#FFB3D9",
|
||||
"#A8E6CF",
|
||||
"#6BCB77",
|
||||
"#C7CEEA",
|
||||
];
|
||||
|
||||
// Color Identity Win Rate Chart
|
||||
const colorLabels = chartData?.colors?.labels || [];
|
||||
const colorData = chartData?.colors?.data || [];
|
||||
const filteredIndices = colorData
|
||||
.map((value, index) => (value > 0 ? index : -1))
|
||||
.filter((index) => index !== -1);
|
||||
|
||||
const filteredLabels = filteredIndices.map((i) => colorLabels[i]);
|
||||
const filteredData = filteredIndices.map((i) => colorData[i]);
|
||||
const filteredColors = filteredIndices.map(
|
||||
(_, index) => pastelColors[index % pastelColors.length],
|
||||
);
|
||||
|
||||
const colorCanvas = document.getElementById("colorWinRateChart");
|
||||
if (colorCanvas) {
|
||||
const colorCtx = colorCanvas.getContext("2d");
|
||||
charts.colorWinRate = new Chart(colorCtx, {
|
||||
type: "doughnut",
|
||||
data: {
|
||||
labels: filteredLabels,
|
||||
datasets: [
|
||||
{
|
||||
data: filteredData,
|
||||
backgroundColor: filteredColors,
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: "right" },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Player Count Win Rate Chart
|
||||
const playerLabels = chartData?.playerCounts?.labels || [];
|
||||
const playerData = chartData?.playerCounts?.data || [];
|
||||
const playerFilteredIndices = playerData
|
||||
.map((value, index) => (value > 0 ? index : -1))
|
||||
.filter((index) => index !== -1);
|
||||
|
||||
const playerFilteredLabels = playerFilteredIndices.map(
|
||||
(i) => playerLabels[i],
|
||||
);
|
||||
const playerFilteredData = playerFilteredIndices.map((i) => playerData[i]);
|
||||
|
||||
const playerCanvas = document.getElementById("playerCountChart");
|
||||
if (playerCanvas) {
|
||||
const playerCtx = playerCanvas.getContext("2d");
|
||||
charts.playerCount = new Chart(playerCtx, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: playerFilteredLabels,
|
||||
datasets: [
|
||||
{
|
||||
label: "Win Rate (%)",
|
||||
data: playerFilteredData,
|
||||
backgroundColor: "#6366f1",
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard - EDH Stats Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Your EDH/Commander game statistics dashboard"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<ProtectedRoute>
|
||||
<div class="min-h-screen bg-gray-50 flex flex-col">
|
||||
<NavBar />
|
||||
|
||||
<main class="container mx-auto px-4 py-8 flex-1">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="loading-spinner w-12 h-12"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Stats Overview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<!-- Total Games -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 mb-1">Total Games</p>
|
||||
<p class="text-3xl font-bold text-gray-900">
|
||||
{stats.totalGames}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Win Rate -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 mb-1">Win Rate</p>
|
||||
<p class="text-3xl font-bold text-gray-900">{stats.winRate}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commanders -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 mb-1">Commanders</p>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<p class="text-3xl font-bold text-gray-900">
|
||||
{stats.totalCommanders}
|
||||
</p>
|
||||
{#if stats.inactiveCommanders > 0}
|
||||
<span class="text-sm text-gray-500">
|
||||
({stats.inactiveCommanders} archived)
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avg Rounds -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 mb-1">Avg Rounds</p>
|
||||
<p class="text-3xl font-bold text-gray-900">
|
||||
{stats.avgRounds}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Color Win Rate Chart -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold mb-6">
|
||||
Win Rate by Color Identity
|
||||
</h3>
|
||||
<div class="h-64 relative">
|
||||
<canvas id="colorWinRateChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Count Chart -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold mb-6">Win Rate by Player Count</h3>
|
||||
<div class="h-64 relative">
|
||||
<canvas id="playerCountChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Games and Top Commanders -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Recent Games -->
|
||||
<div class="card">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900">Recent Games</h2>
|
||||
<a
|
||||
href="/games"
|
||||
class="text-sm text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
View All →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if recentGames.length === 0}
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<p>No games logged yet</p>
|
||||
<a
|
||||
href="/games"
|
||||
class="text-indigo-600 hover:text-indigo-800 mt-2 inline-block"
|
||||
>
|
||||
Log your first game
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each recentGames as game}
|
||||
<div
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900">
|
||||
{game.commanderName}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
{formatDate(game.date)} • {game.rounds} rounds • {game.playerCount}
|
||||
players
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
{#if game.won}
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
>
|
||||
Won
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||
>
|
||||
Loss
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Top Commanders -->
|
||||
<div class="card">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900">Top Commanders</h2>
|
||||
<a
|
||||
href="/commanders"
|
||||
class="text-sm text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
View All →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if topCommanders.length === 0}
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<p>No commanders yet</p>
|
||||
<a
|
||||
href="/commanders"
|
||||
class="text-indigo-600 hover:text-indigo-800 mt-2 inline-block"
|
||||
>
|
||||
Add your first commander
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each topCommanders as commander}
|
||||
<div
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-900">{commander.name}</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
{commander.totalGames} games • {commander.winRate}% win
|
||||
rate
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
732
frontend/src/routes/games/+page.svelte
Normal file
@@ -0,0 +1,732 @@
|
||||
<script>
|
||||
import { onMount, tick } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { authenticatedFetch } from "$stores/auth";
|
||||
import NavBar from "$components/NavBar.svelte";
|
||||
import ProtectedRoute from "$components/ProtectedRoute.svelte";
|
||||
import Footer from "$components/Footer.svelte";
|
||||
|
||||
let showLogForm = false;
|
||||
let games = [];
|
||||
let commanders = [];
|
||||
let allCommanders = [];
|
||||
let loading = false;
|
||||
let loadingMore = false;
|
||||
let limit = 20;
|
||||
let offset = 0;
|
||||
let hasMore = false;
|
||||
let submitting = false;
|
||||
let editingGame = null;
|
||||
let serverError = "";
|
||||
let logFormElement;
|
||||
|
||||
let deleteConfirm = {
|
||||
show: false,
|
||||
gameId: null,
|
||||
deleting: false,
|
||||
};
|
||||
|
||||
let newGame = {
|
||||
date: new Date().toISOString().split("T")[0],
|
||||
commanderId: "",
|
||||
playerCount: 4,
|
||||
won: false,
|
||||
rounds: 8,
|
||||
startingPlayerWon: false,
|
||||
solRingTurnOneWon: false,
|
||||
notes: "",
|
||||
};
|
||||
|
||||
$: formData = editingGame || newGame;
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadCommanders(), loadGames()]);
|
||||
loadPrefilled();
|
||||
});
|
||||
|
||||
async function loadCommanders() {
|
||||
try {
|
||||
const response = await authenticatedFetch("/api/commanders");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
allCommanders = data.commanders || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load commanders:", error);
|
||||
}
|
||||
}
|
||||
|
||||
$: commanders = deriveCommanderOptions(allCommanders, formData?.commanderId);
|
||||
|
||||
async function loadGames({ append = false } = {}) {
|
||||
if (append) {
|
||||
if (loadingMore || !hasMore) return;
|
||||
loadingMore = true;
|
||||
} else {
|
||||
loading = true;
|
||||
offset = 0;
|
||||
hasMore = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const queryOffset = append ? offset : 0;
|
||||
const params = new URLSearchParams({
|
||||
limit: limit.toString(),
|
||||
offset: queryOffset.toString(),
|
||||
});
|
||||
const response = await authenticatedFetch(
|
||||
`/api/games?${params.toString()}`,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const incomingGames = data.games || [];
|
||||
if (append) {
|
||||
const existingIds = new Set(games.map((game) => game.id));
|
||||
const deduped = incomingGames.filter(
|
||||
(game) => !existingIds.has(game.id),
|
||||
);
|
||||
games = [...games, ...deduped];
|
||||
} else {
|
||||
games = incomingGames;
|
||||
}
|
||||
hasMore = data.pagination?.hasMore ?? false;
|
||||
offset = queryOffset + incomingGames.length;
|
||||
} else {
|
||||
console.error("Failed to load games:", await response.text());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load games:", error);
|
||||
} finally {
|
||||
if (append) {
|
||||
loadingMore = false;
|
||||
} else {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
await loadGames({ append: true });
|
||||
}
|
||||
|
||||
async function loadPrefilled() {
|
||||
if (!browser) return;
|
||||
|
||||
const prefilled = localStorage.getItem("edh-prefill-game");
|
||||
if (prefilled) {
|
||||
try {
|
||||
const data = JSON.parse(prefilled);
|
||||
newGame.date = data.date || new Date().toISOString().split("T")[0];
|
||||
newGame.rounds = data.rounds || 8;
|
||||
newGame.notes =
|
||||
`Ended after ${data.rounds} rounds in ${data.duration}.\nAverage time/round: ${data.avgTimePerRound}` ||
|
||||
"";
|
||||
|
||||
showLogForm = true;
|
||||
localStorage.removeItem("edh-prefill-game");
|
||||
|
||||
await tick();
|
||||
logFormElement?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
} catch (error) {
|
||||
console.error("Error loading prefilled game data:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogGame(e) {
|
||||
e.preventDefault();
|
||||
serverError = "";
|
||||
|
||||
if (!formData.commanderId) {
|
||||
serverError = "Please select a commander";
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingGame) {
|
||||
await handleUpdateGame();
|
||||
} else {
|
||||
await handleCreateGame();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateGame() {
|
||||
submitting = true;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
commanderId: formData.commanderId,
|
||||
date: formData.date,
|
||||
playerCount: parseInt(formData.playerCount),
|
||||
won: formData.won === true || formData.won === "true",
|
||||
rounds: parseInt(formData.rounds),
|
||||
startingPlayerWon:
|
||||
formData.startingPlayerWon === true ||
|
||||
formData.startingPlayerWon === "true",
|
||||
solRingTurnOneWon:
|
||||
formData.solRingTurnOneWon === true ||
|
||||
formData.solRingTurnOneWon === "true",
|
||||
};
|
||||
|
||||
if (formData.notes && formData.notes.trim()) {
|
||||
payload.notes = formData.notes;
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch("/api/games", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
games = [data.game, ...games];
|
||||
offset += 1;
|
||||
resetForm();
|
||||
showLogForm = false;
|
||||
|
||||
// Reset the round counter state
|
||||
if (browser) {
|
||||
localStorage.removeItem("edh-round-counter-state");
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
serverError = errorData.message || "Failed to log game";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Log game error:", error);
|
||||
serverError = "Network error occurred";
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateGame() {
|
||||
submitting = true;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
commanderId: formData.commanderId,
|
||||
date: formData.date,
|
||||
playerCount: parseInt(formData.playerCount),
|
||||
won: formData.won === true || formData.won === "true",
|
||||
rounds: parseInt(formData.rounds),
|
||||
startingPlayerWon:
|
||||
formData.startingPlayerWon === true ||
|
||||
formData.startingPlayerWon === "true",
|
||||
solRingTurnOneWon:
|
||||
formData.solRingTurnOneWon === true ||
|
||||
formData.solRingTurnOneWon === "true",
|
||||
};
|
||||
|
||||
if (formData.notes && formData.notes.trim()) {
|
||||
payload.notes = formData.notes;
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
`/api/games/${editingGame.id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
games = games.map((g) => (g.id === data.game.id ? data.game : g));
|
||||
resetForm();
|
||||
editingGame = null;
|
||||
showLogForm = false;
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
serverError = errorData.message || "Failed to update game";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Update game error:", error);
|
||||
serverError = "Network error occurred";
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startEdit(game) {
|
||||
// Map API response to form fields
|
||||
const formattedDate = game.date
|
||||
? new Date(game.date).toISOString().split("T")[0]
|
||||
: new Date().toISOString().split("T")[0];
|
||||
|
||||
// Ensure commanderId is a number to match select options
|
||||
const cmdId = game.commanderId;
|
||||
const finalCmdId = cmdId
|
||||
? typeof cmdId === "number"
|
||||
? cmdId
|
||||
: parseInt(cmdId)
|
||||
: "";
|
||||
|
||||
editingGame = {
|
||||
id: game.id,
|
||||
date: formattedDate,
|
||||
commanderId: finalCmdId,
|
||||
playerCount: game.playerCount || 4,
|
||||
won: game.won || false,
|
||||
rounds: game.rounds || 8,
|
||||
startingPlayerWon: game.startingPlayerWon || false,
|
||||
solRingTurnOneWon: game.solRingTurnOneWon || false,
|
||||
notes: game.notes || "",
|
||||
};
|
||||
|
||||
showLogForm = true;
|
||||
serverError = "";
|
||||
await tick();
|
||||
logFormElement?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingGame = null;
|
||||
showLogForm = false;
|
||||
serverError = "";
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
newGame = {
|
||||
date: new Date().toISOString().split("T")[0],
|
||||
commanderId: "",
|
||||
playerCount: 4,
|
||||
won: false,
|
||||
rounds: 8,
|
||||
startingPlayerWon: false,
|
||||
solRingTurnOneWon: false,
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
function showDeleteConfirm(gameId) {
|
||||
deleteConfirm = {
|
||||
show: true,
|
||||
gameId,
|
||||
deleting: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
deleteConfirm.deleting = true;
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`/api/games/${deleteConfirm.gameId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
games = games.filter((g) => g.id !== deleteConfirm.gameId);
|
||||
offset = Math.max(0, offset - 1);
|
||||
deleteConfirm = { show: false, gameId: null, deleting: false };
|
||||
} else {
|
||||
serverError = "Failed to delete game";
|
||||
deleteConfirm.deleting = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Delete game error:", error);
|
||||
serverError = "Network error occurred";
|
||||
deleteConfirm.deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function deriveCommanderOptions(allList, currentCommanderId) {
|
||||
if (!Array.isArray(allList) || allList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const active = allList.filter((cmd) => !cmd.archived);
|
||||
const normalizedId = normalizeCommanderId(currentCommanderId);
|
||||
|
||||
if (normalizedId === null) {
|
||||
return active;
|
||||
}
|
||||
|
||||
const current = allList.find((cmd) => cmd.id === normalizedId);
|
||||
if (
|
||||
current &&
|
||||
current.archived &&
|
||||
!active.some((cmd) => cmd.id === current.id)
|
||||
) {
|
||||
return [...active, current];
|
||||
}
|
||||
|
||||
return active;
|
||||
}
|
||||
|
||||
function normalizeCommanderId(value) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Game Log - EDH Stats Tracker</title>
|
||||
<meta name="description" content="Log and manage your EDH/Commander games" />
|
||||
</svelte:head>
|
||||
|
||||
<ProtectedRoute>
|
||||
<div class="min-h-screen bg-gray-50 flex flex-col">
|
||||
<NavBar />
|
||||
|
||||
<main class="container mx-auto px-4 py-8 flex-1">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Game Log</h1>
|
||||
<button
|
||||
on:click={() => {
|
||||
showLogForm = !showLogForm;
|
||||
if (!showLogForm) cancelEdit();
|
||||
}}
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{#if showLogForm}
|
||||
Cancel
|
||||
{:else}
|
||||
Log New Game
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Log Game Form -->
|
||||
{#if showLogForm}
|
||||
<div class="card mb-8">
|
||||
<h2 class="text-xl font-bold mb-4">
|
||||
{editingGame ? "Edit Game" : "Log New Game"}
|
||||
</h2>
|
||||
|
||||
{#key editingGame?.id || "new"}
|
||||
<form
|
||||
on:submit={handleLogGame}
|
||||
class="space-y-4"
|
||||
bind:this={logFormElement}
|
||||
>
|
||||
<!-- Date and Commander Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="date"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
id="date"
|
||||
type="date"
|
||||
bind:value={formData.date}
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="commander"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Commander *
|
||||
</label>
|
||||
<select
|
||||
id="commander"
|
||||
bind:value={formData.commanderId}
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value="">Select a commander</option>
|
||||
{#each commanders as commander}
|
||||
<option value={commander.id}>
|
||||
{commander.name}{commander.archived
|
||||
? " (Archived)"
|
||||
: ""}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Count and Rounds -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="playerCount"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Player Count
|
||||
</label>
|
||||
<input
|
||||
id="playerCount"
|
||||
type="number"
|
||||
bind:value={formData.playerCount}
|
||||
min="2"
|
||||
max="8"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="rounds"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Rounds
|
||||
</label>
|
||||
<input
|
||||
id="rounds"
|
||||
type="number"
|
||||
bind:value={formData.rounds}
|
||||
min="1"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checkboxes -->
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={formData.won}
|
||||
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-900">I won this game</span
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={formData.startingPlayerWon}
|
||||
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-900"
|
||||
>Starting player won</span
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={formData.solRingTurnOneWon}
|
||||
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span class="ml-2 text-sm text-gray-900">
|
||||
Player with Sol Ring turn 1 won
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div>
|
||||
<label
|
||||
for="notes"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
bind:value={formData.notes}
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
placeholder="Add any notes about this game..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if serverError}
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<p class="text-sm font-medium text-red-800">{serverError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex-1 btn btn-primary disabled:opacity-50"
|
||||
>
|
||||
{#if submitting}
|
||||
<div class="loading-spinner w-5 h-5 mx-auto"></div>
|
||||
{:else if editingGame}
|
||||
Update Game
|
||||
{:else}
|
||||
Log Game
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if editingGame}
|
||||
<button
|
||||
type="button"
|
||||
on:click={cancelEdit}
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Games List -->
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="loading-spinner w-12 h-12"></div>
|
||||
</div>
|
||||
{:else if games.length === 0}
|
||||
<div class="card text-center py-12">
|
||||
<p class="text-gray-600 mb-4">No games logged yet</p>
|
||||
<button on:click={() => (showLogForm = true)} class="btn btn-primary">
|
||||
Log Your First Game
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each games as game}
|
||||
<div
|
||||
class="card hover:shadow-lg transition-shadow {game.won
|
||||
? 'border-l-4 border-l-green-500'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-bold text-gray-900">
|
||||
{game.commanderName}
|
||||
</h3>
|
||||
{#if game.won}
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
>
|
||||
Won
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||
>
|
||||
Loss
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<p>
|
||||
{formatDate(game.date)} • {game.rounds} rounds • {game.playerCount}
|
||||
players
|
||||
</p>
|
||||
{#if game.startingPlayerWon}
|
||||
<p>• Starting player won</p>
|
||||
{/if}
|
||||
{#if game.solRingTurnOneWon}
|
||||
<p>• Sol Ring turn 1 won</p>
|
||||
{/if}
|
||||
{#if game.notes}
|
||||
<p class="mt-2 text-gray-700">{game.notes}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button
|
||||
on:click={() => startEdit(game)}
|
||||
class="text-indigo-600 hover:text-indigo-800 text-xl font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
on:click={() => showDeleteConfirm(game.id)}
|
||||
class="text-red-600 hover:text-red-800 text-xl font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if hasMore}
|
||||
<div class="flex justify-center pt-6">
|
||||
<button
|
||||
on:click={loadMore}
|
||||
class="btn btn-secondary"
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{#if loadingMore}
|
||||
<div class="loading-spinner w-5 h-5"></div>
|
||||
{:else}
|
||||
Load More
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{:else if hasMore && games.length > 0}
|
||||
<p class="text-center text-gray-500 pt-6">
|
||||
You have reached the end of the line.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if deleteConfirm.show}
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50"
|
||||
>
|
||||
<button
|
||||
class="absolute inset-0 w-full h-full cursor-default"
|
||||
aria-label="Close dialog"
|
||||
on:click={() =>
|
||||
!deleteConfirm.deleting && (deleteConfirm.show = false)}
|
||||
></button>
|
||||
<div class="relative bg-white rounded-lg p-6 max-w-sm w-full mx-4">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">Delete Game</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Are you sure you want to delete this game? This action cannot be
|
||||
undone.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
on:click={confirmDelete}
|
||||
disabled={deleteConfirm.deleting}
|
||||
class="flex-1 btn btn-danger disabled:opacity-50"
|
||||
>
|
||||
{#if deleteConfirm.deleting}
|
||||
<div class="loading-spinner w-5 h-5 mx-auto"></div>
|
||||
{:else}
|
||||
Delete
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
on:click={() => (deleteConfirm.show = false)}
|
||||
disabled={deleteConfirm.deleting}
|
||||
class="flex-1 btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
273
frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,273 @@
|
||||
<script>
|
||||
import { auth } from '$stores/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let formData = {
|
||||
username: '',
|
||||
password: '',
|
||||
remember: false
|
||||
};
|
||||
|
||||
let errors = {};
|
||||
let showPassword = false;
|
||||
let loading = false;
|
||||
let serverError = '';
|
||||
|
||||
function validateUsername() {
|
||||
if (!formData.username.trim()) {
|
||||
errors.username = 'Username is required';
|
||||
} else if (formData.username.length < 3) {
|
||||
errors.username = 'Username must be at least 3 characters';
|
||||
} else {
|
||||
errors.username = '';
|
||||
}
|
||||
}
|
||||
|
||||
function validatePassword() {
|
||||
if (!formData.password) {
|
||||
errors.password = 'Password is required';
|
||||
} else if (formData.password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters';
|
||||
} else {
|
||||
errors.password = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate form
|
||||
validateUsername();
|
||||
validatePassword();
|
||||
|
||||
if (errors.username || errors.password) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
serverError = '';
|
||||
|
||||
const result = await auth.login(
|
||||
formData.username,
|
||||
formData.password,
|
||||
formData.remember
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
goto('/dashboard');
|
||||
} else {
|
||||
serverError = result.error;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login - EDH Stats Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Login to track your Magic: The Gathering EDH/Commander games"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-full flex flex-col py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-center flex-1">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold font-mtg text-edh-primary mb-2">EDH Stats</h1>
|
||||
<h2 class="text-xl text-gray-600">Sign in to your account</h2>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<div class="card">
|
||||
<form class="space-y-6" on:submit={handleLogin}>
|
||||
<!-- Username Field -->
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
bind:value={formData.username}
|
||||
on:input={validateUsername}
|
||||
class="appearance-none block w-full px-3 py-2 pl-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm {errors.username
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{#if errors.username}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.username}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
bind:value={formData.password}
|
||||
on:input={validatePassword}
|
||||
class="appearance-none block w-full px-3 py-2 pl-10 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm {errors.password
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showPassword = !showPassword)}
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400 hover:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{#if showPassword}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{#if errors.password}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Remember Me -->
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="remember"
|
||||
name="remember"
|
||||
type="checkbox"
|
||||
bind:checked={formData.remember}
|
||||
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label for="remember" class="ml-2 block text-sm text-gray-900">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Server Error -->
|
||||
{#if serverError}
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-red-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-red-800">{serverError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="loading-spinner w-5 h-5"></div>
|
||||
{:else}
|
||||
Sign in
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Links -->
|
||||
<div class="mt-6 text-center space-y-2">
|
||||
<p class="text-sm text-gray-600">
|
||||
Don't have an account?
|
||||
<a href="/register" class="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Sign up
|
||||
</a>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
<a href="/" class="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
← Back to Home
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
544
frontend/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,544 @@
|
||||
<script>
|
||||
import { auth, currentUser } from "$stores/auth";
|
||||
import { authenticatedFetch } from "$stores/auth";
|
||||
import NavBar from "$components/NavBar.svelte";
|
||||
import ProtectedRoute from "$components/ProtectedRoute.svelte";
|
||||
import Footer from "$components/Footer.svelte";
|
||||
|
||||
let usernameLoading = false;
|
||||
let passwordLoading = false;
|
||||
let deleteLoading = false;
|
||||
let usernameError = "";
|
||||
let usernameSuccess = "";
|
||||
let passwordError = "";
|
||||
let passwordSuccess = "";
|
||||
let deleteError = "";
|
||||
let showPasswordForm = false;
|
||||
let showUsernameForm = false;
|
||||
let showDeleteConfirm = false;
|
||||
let deleteConfirmText = "";
|
||||
|
||||
let newUsername = "";
|
||||
|
||||
let passwordData = {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
};
|
||||
|
||||
let errors = {};
|
||||
|
||||
function validateUsername() {
|
||||
errors = {};
|
||||
|
||||
if (!newUsername) {
|
||||
errors.username = "Username is required";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newUsername.length < 3) {
|
||||
errors.username = "Username must be at least 3 characters";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newUsername.length > 50) {
|
||||
errors.username = "Username must be less than 50 characters";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(newUsername)) {
|
||||
errors.username =
|
||||
"Username can only contain letters, numbers, underscores, and hyphens";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newUsername === $currentUser?.username) {
|
||||
errors.username = "New username must be different from current username";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleUpdateUsername(e) {
|
||||
e.preventDefault();
|
||||
usernameError = "";
|
||||
usernameSuccess = "";
|
||||
|
||||
if (!validateUsername()) return;
|
||||
|
||||
usernameLoading = true;
|
||||
try {
|
||||
const response = await authenticatedFetch("/api/auth/update-username", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
newUsername: newUsername.toLowerCase().trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
usernameSuccess = "Username updated successfully!";
|
||||
|
||||
// Update the auth store with new user data
|
||||
auth.updateUser(data.user);
|
||||
|
||||
newUsername = "";
|
||||
showUsernameForm = false;
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
usernameError = errorData.message || "Failed to update username";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Update username error:", error);
|
||||
usernameError = "Network error occurred";
|
||||
} finally {
|
||||
usernameLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
if (deleteConfirmText !== $currentUser?.username) return;
|
||||
|
||||
deleteLoading = true;
|
||||
deleteError = "";
|
||||
try {
|
||||
const response = await authenticatedFetch("/api/auth/me", {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Logout clears the store and redirects to /login
|
||||
auth.logout();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
deleteError = errorData.message || "Failed to delete account";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Delete account error:", error);
|
||||
deleteError = "Network error occurred";
|
||||
} finally {
|
||||
deleteLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function validatePasswords() {
|
||||
errors = {};
|
||||
|
||||
if (!passwordData.currentPassword) {
|
||||
errors.currentPassword = "Current password is required";
|
||||
}
|
||||
|
||||
if (!passwordData.newPassword) {
|
||||
errors.newPassword = "New password is required";
|
||||
} else if (passwordData.newPassword.length < 8) {
|
||||
errors.newPassword = "Password must be at least 8 characters";
|
||||
} else if (!/(?=.*[a-z])/.test(passwordData.newPassword)) {
|
||||
errors.newPassword =
|
||||
"Password must contain at least one lowercase letter";
|
||||
} else if (!/(?=.*[A-Z])/.test(passwordData.newPassword)) {
|
||||
errors.newPassword =
|
||||
"Password must contain at least one uppercase letter";
|
||||
} else if (!/(?=.*\d)/.test(passwordData.newPassword)) {
|
||||
errors.newPassword = "Password must contain at least one number";
|
||||
}
|
||||
|
||||
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
||||
errors.confirmPassword = "Passwords do not match";
|
||||
}
|
||||
|
||||
return Object.keys(errors).length === 0;
|
||||
}
|
||||
|
||||
async function handleChangePassword(e) {
|
||||
e.preventDefault();
|
||||
passwordError = "";
|
||||
passwordSuccess = "";
|
||||
|
||||
if (!validatePasswords()) return;
|
||||
|
||||
passwordLoading = true;
|
||||
try {
|
||||
const response = await authenticatedFetch("/api/auth/change-password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
currentPassword: passwordData.currentPassword,
|
||||
newPassword: passwordData.newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
passwordSuccess = "Password changed successfully!";
|
||||
passwordData = {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
};
|
||||
showPasswordForm = false;
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
passwordError = errorData.message || "Failed to change password";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Change password error:", error);
|
||||
passwordError = "Network error occurred";
|
||||
} finally {
|
||||
passwordLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profile - EDH Stats Tracker</title>
|
||||
<meta name="description" content="Manage your profile and settings" />
|
||||
</svelte:head>
|
||||
|
||||
<ProtectedRoute>
|
||||
<div class="min-h-screen bg-gray-50 flex flex-col">
|
||||
<NavBar />
|
||||
|
||||
<main class="container mx-auto px-4 py-8 max-w-2xl flex-1">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-6">Profile Settings</h1>
|
||||
|
||||
<!-- Global Success Messages -->
|
||||
{#if usernameSuccess && !showUsernameForm}
|
||||
<div class="rounded-md bg-green-50 p-4 mb-6">
|
||||
<p class="text-sm font-medium text-green-800">{usernameSuccess}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if passwordSuccess && !showPasswordForm}
|
||||
<div class="rounded-md bg-green-50 p-4 mb-6">
|
||||
<p class="text-sm font-medium text-green-800">{passwordSuccess}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="card mb-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">Account Information</h2>
|
||||
{#if !showUsernameForm}
|
||||
<button
|
||||
on:click={() => {
|
||||
showUsernameForm = true;
|
||||
newUsername = $currentUser?.username || "";
|
||||
usernameError = "";
|
||||
usernameSuccess = "";
|
||||
errors = {};
|
||||
}}
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
Edit Username
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showUsernameForm}
|
||||
<form on:submit={handleUpdateUsername} class="space-y-4 mb-4">
|
||||
<div>
|
||||
<label
|
||||
for="newUsername"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
New Username
|
||||
</label>
|
||||
<input
|
||||
id="newUsername"
|
||||
type="text"
|
||||
bind:value={newUsername}
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.username
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
placeholder="Enter new username"
|
||||
/>
|
||||
{#if errors.username}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.username}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
3-50 characters, letters, numbers, underscores, and hyphens only
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if usernameSuccess}
|
||||
<div class="rounded-md bg-green-50 p-4">
|
||||
<p class="text-sm font-medium text-green-800">
|
||||
{usernameSuccess}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if usernameError}
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<p class="text-sm font-medium text-red-800">{usernameError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={usernameLoading}
|
||||
class="flex-1 btn btn-primary disabled:opacity-50"
|
||||
>
|
||||
{#if usernameLoading}
|
||||
<div class="loading-spinner w-5 h-5 mx-auto"></div>
|
||||
{:else}
|
||||
Update Username
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showUsernameForm = false;
|
||||
newUsername = "";
|
||||
errors = {};
|
||||
usernameError = "";
|
||||
}}
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Current Username</p>
|
||||
<p class="text-lg font-medium text-gray-900">
|
||||
{$currentUser?.username || "User"}
|
||||
</p>
|
||||
</div>
|
||||
{#if $currentUser?.email}
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">Email</p>
|
||||
<p class="text-lg font-medium text-gray-900">
|
||||
{$currentUser.email}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="card">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">Change Password</h2>
|
||||
{#if !showPasswordForm}
|
||||
<button
|
||||
on:click={() => {
|
||||
showPasswordForm = true;
|
||||
passwordError = "";
|
||||
passwordSuccess = "";
|
||||
errors = {};
|
||||
}}
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
Change Password
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showPasswordForm}
|
||||
<form on:submit={handleChangePassword} class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="currentPassword"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
bind:value={passwordData.currentPassword}
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.currentPassword
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
/>
|
||||
{#if errors.currentPassword}
|
||||
<p class="mt-1 text-sm text-red-600">
|
||||
{errors.currentPassword}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="newPassword"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
bind:value={passwordData.newPassword}
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.newPassword
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
/>
|
||||
{#if errors.newPassword}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.newPassword}</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
At least 8 characters with uppercase, lowercase, and a number
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="confirmPassword"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
bind:value={passwordData.confirmPassword}
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.confirmPassword
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
/>
|
||||
{#if errors.confirmPassword}
|
||||
<p class="mt-1 text-sm text-red-600">
|
||||
{errors.confirmPassword}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if passwordSuccess}
|
||||
<div class="rounded-md bg-green-50 p-4">
|
||||
<p class="text-sm font-medium text-green-800">
|
||||
{passwordSuccess}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if passwordError}
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<p class="text-sm font-medium text-red-800">{passwordError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={passwordLoading}
|
||||
class="flex-1 btn btn-primary disabled:opacity-50"
|
||||
>
|
||||
{#if passwordLoading}
|
||||
<div class="loading-spinner w-5 h-5 mx-auto"></div>
|
||||
{:else}
|
||||
Update Password
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showPasswordForm = false;
|
||||
passwordData = {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
};
|
||||
errors = {};
|
||||
}}
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<div class="card mt-6 border border-red-200">
|
||||
<h2 class="text-xl font-bold text-red-600 mb-4">Danger Zone</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Permanently delete your account and all associated data including
|
||||
games, commanders, and statistics. This action cannot be undone.
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
on:click={() => {
|
||||
showDeleteConfirm = true;
|
||||
deleteError = "";
|
||||
deleteConfirmText = "";
|
||||
}}
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if showDeleteConfirm}
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50"
|
||||
>
|
||||
<button
|
||||
class="absolute inset-0 w-full h-full cursor-default"
|
||||
aria-label="Close dialog"
|
||||
on:click={() => !deleteLoading && (showDeleteConfirm = false)}
|
||||
></button>
|
||||
<div class="relative bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-2">Delete Account</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
This will permanently delete your account and all your data. This
|
||||
action <strong>cannot be undone</strong>.
|
||||
</p>
|
||||
<p class="text-sm text-gray-700 mb-2">
|
||||
Type your username <strong>{$currentUser?.username}</strong> to confirm:
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={deleteConfirmText}
|
||||
placeholder="Enter your username"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-red-500 focus:border-red-500 mb-4"
|
||||
/>
|
||||
|
||||
{#if deleteError}
|
||||
<div class="rounded-md bg-red-50 p-3 mb-4">
|
||||
<p class="text-sm font-medium text-red-800">{deleteError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
on:click={handleDeleteAccount}
|
||||
disabled={deleteLoading ||
|
||||
deleteConfirmText !== $currentUser?.username}
|
||||
class="flex-1 btn btn-danger disabled:opacity-50"
|
||||
>
|
||||
{#if deleteLoading}
|
||||
<div class="loading-spinner w-5 h-5 mx-auto"></div>
|
||||
{:else}
|
||||
Delete My Account
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
on:click={() => {
|
||||
showDeleteConfirm = false;
|
||||
deleteConfirmText = "";
|
||||
deleteError = "";
|
||||
}}
|
||||
disabled={deleteLoading}
|
||||
class="flex-1 btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
615
frontend/src/routes/register/+page.svelte
Normal file
@@ -0,0 +1,615 @@
|
||||
<script>
|
||||
import { auth } from "$stores/auth";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let formData = {
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
terms: false,
|
||||
};
|
||||
|
||||
let errors = {};
|
||||
let showPassword = false;
|
||||
let showConfirmPassword = false;
|
||||
let loading = false;
|
||||
let serverError = "";
|
||||
let successMessage = "";
|
||||
let allowRegistration = true;
|
||||
let showTermsModal = false;
|
||||
|
||||
onMount(() => {
|
||||
(async () => {
|
||||
await auth.checkRegistrationConfig();
|
||||
})();
|
||||
|
||||
const unsubscribe = auth.subscribe(($auth) => {
|
||||
allowRegistration = $auth.allowRegistration;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
function validateUsername() {
|
||||
if (!formData.username.trim()) {
|
||||
errors.username = "Username is required";
|
||||
} else if (formData.username.length < 3) {
|
||||
errors.username = "Username must be at least 3 characters";
|
||||
} else if (formData.username.length > 50) {
|
||||
errors.username = "Username must be less than 50 characters";
|
||||
} else if (!/^[a-zA-Z0-9_-]+$/.test(formData.username)) {
|
||||
errors.username =
|
||||
"Username can only contain letters, numbers, underscores, and hyphens";
|
||||
} else {
|
||||
errors.username = "";
|
||||
}
|
||||
}
|
||||
|
||||
function validateEmail() {
|
||||
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
errors.email = "Please enter a valid email address";
|
||||
} else {
|
||||
errors.email = "";
|
||||
}
|
||||
}
|
||||
|
||||
function validatePassword() {
|
||||
if (!formData.password) {
|
||||
errors.password = "Password is required";
|
||||
} else if (formData.password.length < 8) {
|
||||
errors.password = "Password must be at least 8 characters";
|
||||
} else if (formData.password.length > 100) {
|
||||
errors.password = "Password must be less than 100 characters";
|
||||
} else if (!/(?=.*[a-z])/.test(formData.password)) {
|
||||
errors.password = "Password must contain at least one lowercase letter";
|
||||
} else if (!/(?=.*[A-Z])/.test(formData.password)) {
|
||||
errors.password = "Password must contain at least one uppercase letter";
|
||||
} else if (!/(?=.*\d)/.test(formData.password)) {
|
||||
errors.password = "Password must contain at least one number";
|
||||
} else {
|
||||
errors.password = "";
|
||||
}
|
||||
}
|
||||
|
||||
function validateConfirmPassword() {
|
||||
if (!formData.confirmPassword) {
|
||||
errors.confirmPassword = "Please confirm your password";
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
errors.confirmPassword = "Passwords do not match";
|
||||
} else {
|
||||
errors.confirmPassword = "";
|
||||
}
|
||||
}
|
||||
|
||||
function validateTerms() {
|
||||
if (!formData.terms) {
|
||||
errors.terms = "You must agree to the Terms of Service";
|
||||
} else {
|
||||
errors.terms = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate all fields
|
||||
validateUsername();
|
||||
validateEmail();
|
||||
validatePassword();
|
||||
validateConfirmPassword();
|
||||
validateTerms();
|
||||
|
||||
if (Object.values(errors).some((error) => error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
serverError = "";
|
||||
|
||||
const result = await auth.register(
|
||||
formData.username,
|
||||
formData.email,
|
||||
formData.password,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
successMessage = "Account created successfully! Redirecting...";
|
||||
setTimeout(() => {
|
||||
goto("/dashboard");
|
||||
}, 1000);
|
||||
} else {
|
||||
serverError = result.error;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Register - EDH Stats Tracker</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Create an account to track your Magic: The Gathering EDH/Commander games"
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-full flex flex-col py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-center flex-1">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold font-mtg text-edh-primary mb-2">
|
||||
EDH Stats
|
||||
</h1>
|
||||
<h2 class="text-xl text-gray-600">Create your account</h2>
|
||||
</div>
|
||||
|
||||
{#if !allowRegistration}
|
||||
<div class="card">
|
||||
<div class="text-center py-8">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
></path>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-lg font-medium text-gray-900">
|
||||
Registration Closed
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
New user registration is currently disabled.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<a href="/login" class="btn btn-primary"> Go to Login </a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Registration Form -->
|
||||
<div class="card">
|
||||
<form class="space-y-6" on:submit={handleRegister}>
|
||||
<!-- Username -->
|
||||
<div>
|
||||
<label
|
||||
for="username"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
bind:value={formData.username}
|
||||
on:input={validateUsername}
|
||||
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.username
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
placeholder="Choose a username"
|
||||
/>
|
||||
{#if errors.username}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.username}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Email (Optional) -->
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Email (optional)
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={formData.email}
|
||||
on:input={validateEmail}
|
||||
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.email
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
placeholder="your.email@example.com"
|
||||
/>
|
||||
{#if errors.email}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Password *
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
bind:value={formData.password}
|
||||
on:input={validatePassword}
|
||||
class="appearance-none block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.password
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
placeholder="Create a strong password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showPassword = !showPassword)}
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400 hover:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{#if showPassword}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{#if errors.password}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div>
|
||||
<label
|
||||
for="confirmPassword"
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Confirm Password *
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
required
|
||||
bind:value={formData.confirmPassword}
|
||||
on:input={validateConfirmPassword}
|
||||
class="appearance-none block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.confirmPassword
|
||||
? 'border-red-500'
|
||||
: ''}"
|
||||
placeholder="Confirm your password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showConfirmPassword = !showConfirmPassword)}
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400 hover:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{#if showConfirmPassword}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{#if errors.confirmPassword}
|
||||
<p class="mt-1 text-sm text-red-600">
|
||||
{errors.confirmPassword}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Terms -->
|
||||
<div>
|
||||
<div class="flex items-start">
|
||||
<input
|
||||
id="terms"
|
||||
type="checkbox"
|
||||
bind:checked={formData.terms}
|
||||
on:change={validateTerms}
|
||||
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded mt-1"
|
||||
/>
|
||||
<label for="terms" class="ml-2 block text-sm text-gray-900">
|
||||
I agree to the
|
||||
<button
|
||||
type="button"
|
||||
class="text-indigo-600 hover:text-indigo-500 underline-offset-4 underline"
|
||||
on:click={() => (showTermsModal = true)}
|
||||
>
|
||||
Terms of Service
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
{#if errors.terms}
|
||||
<p class="mt-1 text-sm text-red-600">{errors.terms}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
{#if successMessage}
|
||||
<div class="rounded-md bg-green-50 p-4">
|
||||
<p class="text-sm font-medium text-green-800">
|
||||
{successMessage}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Server Error -->
|
||||
{#if serverError}
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<p class="text-sm font-medium text-red-800">{serverError}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submit -->
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{#if loading}
|
||||
<div class="loading-spinner w-5 h-5"></div>
|
||||
{:else}
|
||||
Create Account
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Links -->
|
||||
<div class="mt-6 text-center space-y-2">
|
||||
<p class="text-sm text-gray-600">
|
||||
Already have an account?
|
||||
<a
|
||||
href="/login"
|
||||
class="font-medium text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
<a
|
||||
href="/"
|
||||
class="font-medium text-indigo-600 hover:text-indigo-500"
|
||||
>
|
||||
← Back to Home
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showTermsModal}
|
||||
<div class="fixed inset-0 z-40 flex items-center justify-center">
|
||||
<div
|
||||
class="absolute inset-0 bg-gray-900 bg-opacity-50"
|
||||
on:click={() => (showTermsModal = false)}
|
||||
></div>
|
||||
<div
|
||||
class="relative z-50 w-full max-w-3xl mx-4 bg-white rounded-2xl shadow-2xl flex flex-col max-h-[90vh]"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-gray-200 px-6 py-4"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-wide text-gray-500">
|
||||
Terms of Service
|
||||
</p>
|
||||
<h3 class="text-xl font-semibold text-gray-900">EDH Stats Tracker</h3>
|
||||
</div>
|
||||
<button
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
on:click={() => (showTermsModal = false)}
|
||||
aria-label="Close terms of service"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-6 text-gray-700">
|
||||
<p class="text-gray-600 mb-6 text-sm">Last updated: January 2026</p>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
Welcome to EDH Stats Tracker
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
By creating an account and using EDH Stats Tracker, you agree to
|
||||
these Terms of Service. We've kept them simple and
|
||||
straightforward—no legal jargon that makes your brain hurt. (You're
|
||||
welcome.)
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
1. What This Service Is
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
EDH Stats Tracker is a web application designed to help Magic: The
|
||||
Gathering players track, analyze, and celebrate their EDH/Commander
|
||||
game statistics. We store your game records, commanders, and
|
||||
associated statistics. Think of us as your personal game journal
|
||||
that actually does math for you.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
2. User Accounts
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
You are responsible for maintaining the confidentiality of your
|
||||
password. You agree not to share your account credentials with
|
||||
anyone else. If someone logs into your account and logs all your
|
||||
games as losses, we'll sympathize, but that's on you.
|
||||
</p>
|
||||
<p class="text-gray-700 mb-4">
|
||||
You represent that the information you provide during registration
|
||||
is accurate and true. If you use a fake name, that's between you and
|
||||
Magic's lore team.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
3. Your Content
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
All game records, commander lists, notes, and data you enter into
|
||||
EDH Stats Tracker remain your property. We don't own your stats—we
|
||||
just help you organize them. We won't sell your data, trade it for
|
||||
pack equity, or share it with strangers. (We're not monsters.)
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
4. Acceptable Use
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
You agree to use EDH Stats Tracker for its intended purpose:
|
||||
tracking and analyzing your EDH games. Don't use it to harass,
|
||||
deceive, or cause harm to others. Be cool.
|
||||
</p>
|
||||
<p class="text-gray-700 mb-4">
|
||||
Don't try to break the service through hacking, automated attacks,
|
||||
or other malicious means. If you find a security vulnerability,
|
||||
please let us know responsibly instead.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
5. Service Availability
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
We aim to keep EDH Stats Tracker available and reliable. However,
|
||||
like all software, it may occasionally go down for maintenance or
|
||||
experience technical issues. We're doing our best here.
|
||||
</p>
|
||||
<p class="text-gray-700 mb-4">
|
||||
We reserve the right to make changes to the service, add features,
|
||||
or modify functionality as we see fit. We'll try to keep breaking
|
||||
changes to a minimum.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
6. Limitation of Liability
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
EDH Stats Tracker is provided "as is." While we work hard to make it
|
||||
great, we don't guarantee it will be perfect or meet every need.
|
||||
We're not liable for data loss, lost wins, or your opponent's lucky
|
||||
top-decks.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
7. Changes to Terms
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
We may update these Terms of Service from time to time. We'll let
|
||||
you know about significant changes. Your continued use of the
|
||||
service after changes means you accept the new terms.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
8. Account Termination
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
You can delete your account at any time. Your data will be removed
|
||||
from our systems in accordance with our privacy practices. If you
|
||||
violate these terms, we may disable your account.
|
||||
</p>
|
||||
|
||||
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
|
||||
9. Questions?
|
||||
</h2>
|
||||
<p class="text-gray-700 mb-4">
|
||||
If you have questions about these terms, please reach out. We're
|
||||
reasonable people (at least we think so).
|
||||
</p>
|
||||
|
||||
<div class="mt-8 p-6 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p class="text-blue-900 text-sm">
|
||||
<strong>TL;DR:</strong> Use the service as intended, keep your password
|
||||
safe, it's your responsibility. We'll keep your data private and try
|
||||
to keep the service running. Don't be a jerk. That's it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 px-6 py-4 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900"
|
||||
on:click={() => (showTermsModal = false)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
on:click={() => {
|
||||
showTermsModal = false;
|
||||
formData.terms = true;
|
||||
errors.terms = "";
|
||||
}}
|
||||
>
|
||||
I Agree
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
302
frontend/src/routes/round-counter/+page.svelte
Normal file
@@ -0,0 +1,302 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { goto } from "$app/navigation";
|
||||
import NavBar from "$components/NavBar.svelte";
|
||||
import ProtectedRoute from "$components/ProtectedRoute.svelte";
|
||||
import Footer from "$components/Footer.svelte";
|
||||
|
||||
let counterActive = false;
|
||||
let currentRound = 1;
|
||||
let startTime = null;
|
||||
let elapsedTime = "00:00:00";
|
||||
let avgTimePerRound = "00:00";
|
||||
let timerInterval = null;
|
||||
let hasPausedGame = false;
|
||||
let pausedElapsedTime = 0;
|
||||
|
||||
onMount(() => {
|
||||
loadCounter();
|
||||
if (counterActive) {
|
||||
startTimer();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimer();
|
||||
});
|
||||
|
||||
function startCounter() {
|
||||
counterActive = true;
|
||||
hasPausedGame = false;
|
||||
|
||||
if (!startTime) {
|
||||
startTime = new Date();
|
||||
} else {
|
||||
// Resuming: adjust start time for paused duration
|
||||
startTime = new Date(Date.now() - pausedElapsedTime * 1000);
|
||||
}
|
||||
|
||||
saveCounter();
|
||||
startTimer();
|
||||
}
|
||||
|
||||
function stopCounter() {
|
||||
counterActive = false;
|
||||
hasPausedGame = true;
|
||||
|
||||
if (startTime) {
|
||||
pausedElapsedTime = Math.floor((Date.now() - new Date(startTime)) / 1000);
|
||||
}
|
||||
|
||||
clearTimer();
|
||||
saveCounter();
|
||||
}
|
||||
|
||||
function nextRound() {
|
||||
currentRound++;
|
||||
saveCounter();
|
||||
}
|
||||
|
||||
function resetCounter() {
|
||||
counterActive = false;
|
||||
hasPausedGame = false;
|
||||
currentRound = 1;
|
||||
startTime = null;
|
||||
elapsedTime = "00:00:00";
|
||||
avgTimePerRound = "00:00";
|
||||
pausedElapsedTime = 0;
|
||||
clearTimer();
|
||||
saveCounter();
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
clearTimer();
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
updateTimer();
|
||||
}
|
||||
|
||||
function clearTimer() {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
if (!startTime) return;
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = Math.floor((now - new Date(startTime)) / 1000);
|
||||
|
||||
const hours = Math.floor(elapsed / 3600);
|
||||
const minutes = Math.floor((elapsed % 3600) / 60);
|
||||
const seconds = elapsed % 60;
|
||||
|
||||
elapsedTime = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
|
||||
// Calculate average time per round
|
||||
if (currentRound > 0) {
|
||||
const avgSeconds = Math.floor(elapsed / currentRound);
|
||||
const avgMins = Math.floor(avgSeconds / 60);
|
||||
const avgSecs = avgSeconds % 60;
|
||||
avgTimePerRound = `${String(avgMins).padStart(2, "0")}:${String(avgSecs).padStart(2, "0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function saveCounter() {
|
||||
if (!browser) return;
|
||||
|
||||
localStorage.setItem(
|
||||
"edh-round-counter-state",
|
||||
JSON.stringify({
|
||||
counterActive,
|
||||
currentRound,
|
||||
startTime,
|
||||
elapsedTime,
|
||||
avgTimePerRound,
|
||||
hasPausedGame,
|
||||
pausedElapsedTime,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function loadCounter() {
|
||||
if (!browser) return;
|
||||
|
||||
const saved = localStorage.getItem("edh-round-counter-state");
|
||||
if (saved) {
|
||||
try {
|
||||
const data = JSON.parse(saved);
|
||||
counterActive = data.counterActive || false;
|
||||
currentRound = data.currentRound || 1;
|
||||
startTime = data.startTime ? new Date(data.startTime) : null;
|
||||
elapsedTime = data.elapsedTime || "00:00:00";
|
||||
avgTimePerRound = data.avgTimePerRound || "00:00";
|
||||
hasPausedGame = data.hasPausedGame || false;
|
||||
pausedElapsedTime = data.pausedElapsedTime || 0;
|
||||
} catch (error) {
|
||||
console.error("Error loading counter:", error);
|
||||
resetCounter();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveAndGoToGameLog() {
|
||||
if (!browser) return;
|
||||
|
||||
const now = new Date();
|
||||
localStorage.setItem(
|
||||
"edh-prefill-game",
|
||||
JSON.stringify({
|
||||
date: now.toISOString().split("T")[0],
|
||||
rounds: currentRound,
|
||||
duration: elapsedTime,
|
||||
startTime: startTime ? new Date(startTime).toISOString() : null,
|
||||
endTime: now.toISOString(),
|
||||
avgTimePerRound,
|
||||
}),
|
||||
);
|
||||
|
||||
goto("/games");
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Round Counter - EDH Stats Tracker</title>
|
||||
<meta name="description" content="Track your game rounds and duration" />
|
||||
</svelte:head>
|
||||
|
||||
<ProtectedRoute>
|
||||
<div class="min-h-screen bg-gray-50 flex flex-col">
|
||||
<NavBar />
|
||||
|
||||
<main class="container mx-auto px-4 py-8 flex-1">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-8 text-center">
|
||||
Round Counter
|
||||
</h1>
|
||||
|
||||
<div class="card">
|
||||
<!-- Round Display -->
|
||||
<div class="text-center mb-8">
|
||||
<p class="text-2xl text-gray-600 mb-2">Current Round</p>
|
||||
<p class="text-8xl font-bold text-indigo-600 mb-6">
|
||||
{currentRound}
|
||||
</p>
|
||||
<button
|
||||
on:click={nextRound}
|
||||
disabled={!counterActive && !hasPausedGame}
|
||||
class="btn btn-primary text-lg px-8 py-3 disabled:opacity-50"
|
||||
>
|
||||
Next Round →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Timer Display -->
|
||||
<div class="text-center mb-8 border-t pt-8">
|
||||
<p class="text-xl text-gray-600 mb-2">Elapsed Time</p>
|
||||
<p class="text-5xl font-bold text-gray-900 font-mono mb-4">
|
||||
{elapsedTime}
|
||||
</p>
|
||||
<p class="text-lg text-gray-600">
|
||||
Average per round: <span class="font-semibold"
|
||||
>{avgTimePerRound}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex gap-4 mb-6">
|
||||
{#if !counterActive && !hasPausedGame}
|
||||
<button
|
||||
on:click={startCounter}
|
||||
class="flex-1 btn btn-primary text-lg py-3 icon-button"
|
||||
>
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 5v14l11-7-11-7z" fill="currentColor" />
|
||||
</svg>
|
||||
<span>Start</span>
|
||||
</button>
|
||||
{:else if counterActive}
|
||||
<button
|
||||
on:click={stopCounter}
|
||||
class="flex-1 btn btn-secondary text-lg py-3 icon-button"
|
||||
>
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 5h4v14H6V5zm8 0h4v14h-4V5z" fill="currentColor" />
|
||||
</svg>
|
||||
<span>Pause</span>
|
||||
</button>
|
||||
{:else if hasPausedGame}
|
||||
<button
|
||||
on:click={startCounter}
|
||||
class="flex-1 btn btn-primary text-lg py-3 icon-button"
|
||||
>
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 5v14l11-7-11-7z" fill="currentColor" />
|
||||
</svg>
|
||||
<span>Resume</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
on:click={resetCounter}
|
||||
class="flex-1 btn btn-secondary text-lg py-3 icon-button"
|
||||
disabled={!counterActive && !hasPausedGame}
|
||||
class:opacity-50={!counterActive && !hasPausedGame}
|
||||
>
|
||||
<svg
|
||||
class="btn-icon btn-icon-reset"
|
||||
viewBox="0 0 512 512"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M480.1 192l7.9 0c13.3 0 24-10.7 24-24l0-144c0-9.7-5.8-18.5-14.8-22.2S477.9 .2 471 7L419.3 58.8C375 22.1 318 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1C79.2 135.5 159.3 64 256 64 300.4 64 341.2 79 373.7 104.3L327 151c-6.9 6.9-8.9 17.2-5.2 26.2S334.3 192 344 192l136.1 0zm29.4 100.5c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-44.4 0-85.2-15-117.7-40.3L185 361c6.9-6.9 8.9-17.2 5.2-26.2S177.7 320 168 320L24 320c-13.3 0-24 10.7-24 24L0 488c0 9.7 5.8 18.5 14.8 22.2S34.1 511.8 41 505l51.8-51.8C137 489.9 194 512 256 512 385 512 491.7 416.6 509.4 292.5z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Save Game Button -->
|
||||
{#if counterActive || hasPausedGame}
|
||||
<div class="text-center border-t pt-6">
|
||||
<button
|
||||
on:click={saveAndGoToGameLog}
|
||||
class="btn btn-primary text-white px-8 py-4 text-lg font-bold w-full"
|
||||
>
|
||||
End Game & Log Results
|
||||
</button>
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
This will take you to the game log with pre-filled data
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
|
||||
<style>
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-icon-reset {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
/* Alpine.js x-cloak - hide elements until Alpine initializes */
|
||||
/* Utility to hide elements until JS initializes */
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
4
frontend/static/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" fill="#6366f1" rx="15"/>
|
||||
<text x="50" y="70" font-size="60" text-anchor="middle" fill="white" font-weight="bold">E</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 227 B |
BIN
frontend/static/fonts/Beleren-Bold.ttf
Normal file
BIN
frontend/static/images/B.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/static/images/C.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/static/images/G.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/static/images/R.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/static/images/U.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/static/images/W.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/static/images/commanders.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
frontend/static/images/logs.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
frontend/static/images/stats.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
frontend/static/images/timer.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
1
frontend/static/version.txt
Normal file
@@ -0,0 +1 @@
|
||||
2.4.1
|
||||
25
frontend/svelte.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: true
|
||||
}),
|
||||
alias: {
|
||||
$lib: 'src/lib',
|
||||
$components: 'src/lib/components',
|
||||
$stores: 'src/lib/stores',
|
||||
$utils: 'src/lib/utils'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,7 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: false,
|
||||
content: ['./public/**/*.html', './public/**/*.js', './js/**/*.js'],
|
||||
export default {
|
||||
darkMode: 'media',
|
||||
content: [
|
||||
'./src/**/*.{html,js,svelte,ts}',
|
||||
'./public/**/*.html',
|
||||
'./public/**/*.js',
|
||||
'./js/**/*.js'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
|
||||
22
frontend/vite.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const dockerBackendHost =
|
||||
process.env.VITE_DOCKER_BACKEND_HOST || 'edh-stats-backend'
|
||||
|
||||
const proxyTarget =
|
||||
process.env.VITE_PROXY_TARGET ||
|
||||
(process.env.DOCKER ? `http://${dockerBackendHost}:3000` : 'http://localhost:3002')
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||