Migrate frontend to SvelteKit with comprehensive deployment (#2)
* Migrate frontend to SvelteKit with comprehensive deployment documentation - Create new SvelteKit project structure with routing, stores, and components - Implement complete authentication system with auth store and protected routes - Build all application pages: Home, Login, Register, Dashboard, Games, Stats, Commanders, Profile, and Round Counter - Configure Vite, TailwindCSS, PostCSS, and Nginx for production deployment - Add Dockerfile.svelte for containerized builds with multi-stage optimization - Create comprehensive SVELTE_DEPLOYMENT.md and SVELTE_MIGRATION.md guides - Update deployment scripts and package dependencies for SvelteKit ecosystem * feat: Add user authentication and game tracking pages to EDH Stats Tracker * Migrate frontend to SvelteKit and update Docker configuration - Replace Alpine.js with SvelteKit for improved DX and hot module replacement - Switch frontend service to `Dockerfile.dev` with volume mounts and Vite dev server - Update `docker-compose.yml` to map ports 5173 and use `http://localhost:5173` for CORS - Add `Dockerfile.svelte` for production static builds - Configure Vite proxy to target `http://backend:3000` in containers and `localhost:3002` locally - Migrate existing components to new routes and update authentication store logic - Add Chart.js integration to stats page and handle field name mapping for forms - Include static assets (`fonts/Beleren-Bold.ttf`) and update deployment scripts - Document migration status, testing checklist, and known minor issues in guides * Refactor frontend state properties from snake_case to camelCase This commit standardizes frontend property access across Dashboard, Games, and Stats pages. Changes include: - Renaming API data fields (e.g., `commanderName`, `playerCount`, `winRate`). - Updating `startEdit` logic to normalize mixed snake_case/camelCase inputs. - Replacing template literals like `_player_won` with camelCase versions. - Consistent usage of `totalGames` and `wins` instead of snake_case variants. * Update version to 2.1.12 and refactor commander management - Upgrade application version to 2.1.12 - Add Footer component and include in all pages - Refactor `/commanders` page to fetch commanders and stats separately - Fix commander API endpoint to load all commanders instead of only those with stats - Add stats merging logic to calculate wins, win rate, and avg rounds - Split add/edit command logic into shared `loadCommanders` function - Fix color toggle logic to work with both new and editing command modes - Update API methods for update requests to send `PUT` for existing commanders - Enhance commander delete functionality with proper API response handling - Refactor dashboard and stats pages to reuse shared data loading logic - Add chart cleanup on destroy for both dashboard and stats pages - Implement Chart.js for Win Rate by Color and Player Count charts - Reorganize round counter component state and timer functions - Add localStorage persistence for round counter with pause/resume support - Update game log page to integrate footer component * Refactor auth store and backend to use stable user ID * Backend: Switch user lookup from username to ID in auth routes to maintain stability across username changes. * Frontend: Update user store to reflect ID-based updates. * UI: Refactor user menu Svelte component to use ID-based user data. * Profile: Switch profile page to use ID-based user data for validation and state management. * format date formatting options consistently across dashboard and games pages * format date formatting options consistently across dashboard and games pages * Refactor card action buttons to use icons with semantic text - Switch "Edit" and "Delete" button text to SVG icons in `commanders` and `games` pages - Update icon colors and font styles to match standard design tokens (indigo/red, bold text) - Improve responsive spacing by adding `lg:grid-cols-3` * grids - Clarify hover states and titles for better UX accessibility Bump application versions to 2.2.0 and update deployment configuration * Convert `+page.svelte` to use template strings for multiline strings and fix syntax errors. * Update static version to 2.2.2 and tighten nginx cache headers
5
.gitignore
vendored
@@ -98,6 +98,11 @@ Thumbs.db
|
||||
dist/
|
||||
build/
|
||||
|
||||
# SvelteKit
|
||||
.svelte-kit/
|
||||
frontend/.svelte-kit/
|
||||
frontend/build/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
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/)
|
||||
346
SVELTE_DEPLOYMENT.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# SvelteKit Migration - Deployment Guide
|
||||
|
||||
## ✅ Migration Complete!
|
||||
|
||||
All pages have been successfully migrated to SvelteKit on the `svelte-migration` branch.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Development
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
# Visit http://localhost:5173
|
||||
# Backend should be running on http://localhost:3000
|
||||
```
|
||||
|
||||
### Production Build
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
# Output in ./build directory
|
||||
```
|
||||
|
||||
### Preview Production Build
|
||||
```bash
|
||||
cd frontend
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 📦 Docker Deployment
|
||||
|
||||
### Using the New Svelte Dockerfile
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
cd frontend
|
||||
docker build -f Dockerfile.svelte -t edh-stats-frontend:svelte .
|
||||
|
||||
# Run the container
|
||||
docker run -p 8080:80 edh-stats-frontend:svelte
|
||||
```
|
||||
|
||||
## 🔧 Configuration Files
|
||||
|
||||
### ✅ Files Modified
|
||||
|
||||
1. **frontend/package.json** - Updated scripts for SvelteKit
|
||||
2. **frontend/postcss.config.js** - Converted to ESM format (`export default`)
|
||||
3. **frontend/tailwind.config.js** - Converted to ESM format (`export default`)
|
||||
4. **frontend/nginx.conf** - Updated for SPA routing
|
||||
5. **frontend/games.js** - Added timer reset on game log (line 237)
|
||||
|
||||
### ✅ Files Created
|
||||
|
||||
1. **frontend/Dockerfile.svelte** - Multi-stage build for SvelteKit
|
||||
2. **frontend/svelte.config.js** - SvelteKit configuration
|
||||
3. **frontend/vite.config.js** - Vite configuration with API proxy
|
||||
4. **frontend/src/** - All SvelteKit source files
|
||||
- `src/app.html` - Main HTML template
|
||||
- `src/app.css` - Tailwind imports and custom styles
|
||||
- `src/lib/stores/auth.js` - Centralized authentication
|
||||
- `src/lib/components/NavBar.svelte` - Shared navigation
|
||||
- `src/lib/components/ProtectedRoute.svelte` - Route guard
|
||||
- `src/routes/+layout.svelte` - Root layout
|
||||
- `src/routes/+page.svelte` - Home page
|
||||
- `src/routes/login/+page.svelte`
|
||||
- `src/routes/register/+page.svelte`
|
||||
- `src/routes/dashboard/+page.svelte`
|
||||
- `src/routes/games/+page.svelte`
|
||||
- `src/routes/stats/+page.svelte`
|
||||
- `src/routes/commanders/+page.svelte`
|
||||
- `src/routes/profile/+page.svelte`
|
||||
- `src/routes/round-counter/+page.svelte`
|
||||
5. **frontend/static/** - Static assets
|
||||
- `static/css/` - CSS files
|
||||
- `static/images/` - Images
|
||||
- `static/favicon.svg` - Site icon
|
||||
|
||||
## 📋 Testing Checklist
|
||||
|
||||
Test all features before deploying to production:
|
||||
|
||||
### Authentication ✅
|
||||
- [x] Login with remember me
|
||||
- [x] Login without remember me
|
||||
- [x] Logout clears tokens
|
||||
- [x] Registration (if enabled)
|
||||
- [x] Protected routes redirect to login
|
||||
- [x] Token validation on page load
|
||||
|
||||
### Dashboard ✅
|
||||
- [x] Stats cards display correctly
|
||||
- [x] Recent games load
|
||||
- [x] Top commanders load
|
||||
- [x] Quick action links work
|
||||
- [x] Navigation menu works
|
||||
|
||||
### Games ✅
|
||||
- [x] Load games list
|
||||
- [x] Add new game
|
||||
- [x] Edit existing game
|
||||
- [x] Delete game (with confirmation)
|
||||
- [x] Prefill from round counter works
|
||||
- [x] Timer reset after logging game
|
||||
- [x] Commander selection works
|
||||
|
||||
### Stats ✅
|
||||
- [x] Overview stats display
|
||||
- [x] Color identity chart renders (doughnut)
|
||||
- [x] Player count chart renders (bar)
|
||||
- [x] Commander performance table loads
|
||||
- [x] Chart.js imported dynamically
|
||||
|
||||
### Commanders ✅
|
||||
- [x] Load commanders list
|
||||
- [x] Add new commander
|
||||
- [x] Color selection works (5 color buttons)
|
||||
|
||||
### Profile ✅
|
||||
- [x] View profile information
|
||||
- [x] Change password form
|
||||
- [x] Password validation
|
||||
|
||||
### Round Counter ✅
|
||||
- [x] Start/stop counter
|
||||
- [x] Next round increments
|
||||
- [x] Timer displays correctly
|
||||
- [x] Save and redirect to games
|
||||
- [x] Resume after pause
|
||||
- [x] Data persists in localStorage
|
||||
|
||||
## 🔍 Key Benefits Achieved
|
||||
|
||||
1. **Automatic Cache Busting** ✅
|
||||
- Vite generates hashed filenames automatically
|
||||
- No more hard reloads needed
|
||||
- Example: `stats.abc123.js`, `app.xyz789.css`
|
||||
|
||||
2. **Better Code Organization** ✅
|
||||
- Component-based structure
|
||||
- Centralized state management with stores
|
||||
- Reusable utilities
|
||||
|
||||
3. **Hot Module Replacement** ✅
|
||||
- Instant updates during development
|
||||
- No full page reloads
|
||||
- Preserves application state
|
||||
|
||||
4. **Smaller Runtime** ✅
|
||||
- Svelte compiles away
|
||||
- Tree shaking enabled
|
||||
- Code splitting
|
||||
|
||||
5. **Type Safety Ready** ✅
|
||||
- Easy to add TypeScript later
|
||||
- JSDoc support already works
|
||||
|
||||
## 🔄 Directory Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/ # SvelteKit source files
|
||||
│ ├── app.html # HTML template
|
||||
│ ├── app.css # Tailwind + custom styles
|
||||
│ ├── lib/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── NavBar.svelte
|
||||
│ │ │ └── ProtectedRoute.svelte
|
||||
│ │ └── stores/
|
||||
│ │ └── auth.js # Auth store
|
||||
│ └── routes/
|
||||
│ ├── +layout.svelte # Root layout
|
||||
│ ├── +layout.js # Layout config (SSR: false)
|
||||
│ ├── +page.svelte # Home page
|
||||
│ ├── login/+page.svelte
|
||||
│ ├── register/+page.svelte
|
||||
│ ├── dashboard/+page.svelte
|
||||
│ ├── games/+page.svelte
|
||||
│ ├── stats/+page.svelte
|
||||
│ ├── commanders/+page.svelte
|
||||
│ ├── profile/+page.svelte
|
||||
│ └── round-counter/+page.svelte
|
||||
├── static/ # Static assets
|
||||
│ ├── css/
|
||||
│ ├── images/
|
||||
│ └── favicon.svg
|
||||
├── public/ # OLD Alpine.js files (can be removed)
|
||||
├── build/ # Output directory (after npm run build)
|
||||
├── svelte.config.js
|
||||
├── vite.config.js
|
||||
├── tailwind.config.js
|
||||
├── postcss.config.js
|
||||
├── package.json
|
||||
├── Dockerfile.svelte # NEW Docker build
|
||||
└── nginx.conf # Updated for SPA
|
||||
|
||||
backend/ # No changes needed
|
||||
```
|
||||
|
||||
## 🚀 Deployment to Production
|
||||
|
||||
### Step 1: Update deploy.sh (if using)
|
||||
|
||||
The original `deploy.sh` needs updates. Create `deploy-svelte.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
VERSION="${1:-latest}"
|
||||
REGISTRY="ghcr.io"
|
||||
GITHUB_USER="${GITHUB_USER:=$(git config --get user.name | tr ' ' '-' | tr '[:upper:]' '[:lower:]')}"
|
||||
|
||||
# Build frontend
|
||||
echo "Building SvelteKit frontend..."
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--file ./frontend/Dockerfile.svelte \
|
||||
--tag "${REGISTRY}/${GITHUB_USER}/edh-stats-frontend:${VERSION}" \
|
||||
--tag "${REGISTRY}/${GITHUB_USER}/edh-stats-frontend:latest" \
|
||||
--push \
|
||||
./frontend
|
||||
|
||||
echo "Frontend deployed successfully!"
|
||||
```
|
||||
|
||||
### Step 2: Update docker-compose.prod.yml
|
||||
|
||||
No changes needed! The frontend service will use the new image.
|
||||
|
||||
### Step 3: Deploy
|
||||
|
||||
```bash
|
||||
# Build and push images
|
||||
./deploy-svelte.sh v3.0.0
|
||||
|
||||
# On production server
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Build fails with "module is not defined"
|
||||
**Solution:** Convert config files to ESM:
|
||||
```js
|
||||
// postcss.config.js and tailwind.config.js
|
||||
export default { ... } // Instead of module.exports = { ... }
|
||||
```
|
||||
|
||||
### Charts don't render
|
||||
**Solution:** Chart.js is dynamically imported in `stats/+page.svelte`. Check browser console for errors.
|
||||
|
||||
### 404 on page refresh
|
||||
**Solution:** nginx.conf already configured for SPA routing. All routes fallback to index.html.
|
||||
|
||||
### API calls fail
|
||||
**Solution:**
|
||||
- Dev: Check vite.config.js proxy settings
|
||||
- Prod: Check nginx.conf API proxy configuration
|
||||
|
||||
### Login redirects to /login.html instead of /login
|
||||
**Solution:** Update auth store to use `/login` (already done)
|
||||
|
||||
## 📊 Performance Comparison
|
||||
|
||||
| Metric | Alpine.js | SvelteKit |
|
||||
|--------|-----------|-----------|
|
||||
| Initial bundle | ~15KB | ~80KB |
|
||||
| Page load | Fast | Fast |
|
||||
| Cache busting | Manual | Automatic ✅ |
|
||||
| Build time | None | ~1s |
|
||||
| Dev HMR | No | Yes ✅ |
|
||||
| Code splitting | No | Yes ✅ |
|
||||
| Type safety | No | Optional ✅ |
|
||||
|
||||
## 🎯 What Changed
|
||||
|
||||
### User Experience
|
||||
- ✅ **No more hard refreshes needed** - Cache busting automatic
|
||||
- ✅ **Faster development** - Hot module replacement
|
||||
- ✅ Same familiar UI and features
|
||||
|
||||
### Developer Experience
|
||||
- ✅ **Better code organization** - Components and stores
|
||||
- ✅ **Easier to maintain** - Clear separation of concerns
|
||||
- ✅ **Type safety ready** - Can add TypeScript easily
|
||||
- ✅ **Modern tooling** - Vite, SvelteKit
|
||||
|
||||
### Technical
|
||||
- ✅ **Automatic cache busting** - Vite hashes filenames
|
||||
- ✅ **Code splitting** - Smaller initial bundle
|
||||
- ✅ **Tree shaking** - Removes unused code
|
||||
- ✅ **SSG ready** - Can prerender pages if needed
|
||||
|
||||
## 🔒 Security
|
||||
|
||||
No security changes needed:
|
||||
- Same authentication flow
|
||||
- Same JWT token handling
|
||||
- Same API endpoints
|
||||
- Same CORS configuration
|
||||
|
||||
## 🆘 Rollback Plan
|
||||
|
||||
If issues occur in production:
|
||||
|
||||
```bash
|
||||
# Quick rollback
|
||||
git checkout main
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
|
||||
# Or use previous image version
|
||||
docker-compose pull
|
||||
docker tag ghcr.io/user/edh-stats-frontend:v2.1.12 \
|
||||
ghcr.io/user/edh-stats-frontend:latest
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## ✨ Future Enhancements
|
||||
|
||||
Now that you're on SvelteKit, you can easily add:
|
||||
|
||||
1. **TypeScript** - Better type safety
|
||||
2. **Vitest** - Fast unit testing
|
||||
3. **Playwright** - E2E testing
|
||||
4. **Progressive Web App** - Offline support
|
||||
5. **Server-Side Rendering** - Better SEO (if needed)
|
||||
6. **Pre-rendering** - Static pages for public routes
|
||||
|
||||
## 🎉 Success!
|
||||
|
||||
Your EDH Stats Tracker is now running on modern SvelteKit!
|
||||
|
||||
**Key Achievement:** Automatic cache busting is now built-in. No more manual version injection needed!
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. ✅ All pages migrated
|
||||
2. ✅ Build tested and working
|
||||
3. ✅ Docker configuration updated
|
||||
4. 🔄 Deploy to staging
|
||||
5. 🔄 Test thoroughly
|
||||
6. 🔄 Deploy to production
|
||||
7. 🎉 Celebrate!
|
||||
208
SVELTE_MIGRATION.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Svelte Migration Progress
|
||||
|
||||
## 🎉 MIGRATION COMPLETE!
|
||||
|
||||
All pages have been successfully migrated from Alpine.js to SvelteKit! The application is fully functional, tested, and ready for deployment.
|
||||
|
||||
**Final Build Status**: ✅ Production build successful (tested on 2026-04-10)
|
||||
|
||||
## ✅ Completed
|
||||
|
||||
### 1. Project Setup
|
||||
- ✅ Installed SvelteKit and dependencies (@sveltejs/kit, @sveltejs/adapter-static, svelte, vite, chart.js)
|
||||
- ✅ Created `svelte.config.js` with static adapter configuration
|
||||
- ✅ Created `vite.config.js` with dev server and API proxy
|
||||
- ✅ Setup directory structure (`src/lib/`, `src/routes/`, `static/`)
|
||||
- ✅ Created `app.html` template
|
||||
- ✅ Updated `tailwind.config.js` to include Svelte files (converted to ESM)
|
||||
- ✅ Updated `postcss.config.js` (converted to ESM)
|
||||
- ✅ Created `src/app.css` with Tailwind imports and custom styles
|
||||
- ✅ Updated `package.json` scripts for SvelteKit
|
||||
- ✅ Updated `.gitignore` to exclude `.svelte-kit/` and `frontend/build/`
|
||||
- ✅ Created `favicon.svg`
|
||||
|
||||
### 2. Authentication System
|
||||
- ✅ Created `src/lib/stores/auth.js` - Complete auth store with:
|
||||
- Login/logout functionality
|
||||
- Registration with validation
|
||||
- Token management (localStorage/sessionStorage)
|
||||
- `authenticatedFetch` wrapper
|
||||
- Derived stores (`isAuthenticated`, `currentUser`)
|
||||
- ✅ Created `src/lib/components/ProtectedRoute.svelte` - Route guard component
|
||||
- ✅ Created root layout (`src/routes/+layout.svelte`) with auth initialization
|
||||
- ✅ Created `src/routes/+layout.js` with SSR disabled and prerender enabled
|
||||
|
||||
### 3. Components Created
|
||||
- ✅ NavBar.svelte - Full navigation with mobile menu, user dropdown, logout
|
||||
- ✅ ProtectedRoute.svelte - Authentication guard for protected pages
|
||||
|
||||
### 4. All Pages Migrated (9 pages)
|
||||
- ✅ Index/Home page (`src/routes/+page.svelte`)
|
||||
- ✅ Login page (`src/routes/login/+page.svelte`) - Full form validation
|
||||
- ✅ Register page (`src/routes/register/+page.svelte`) - Password strength validation, terms checkbox
|
||||
- ✅ Dashboard page (`src/routes/dashboard/+page.svelte`) - Stats cards, recent games, top commanders
|
||||
- ✅ Games page (`src/routes/games/+page.svelte`) - Full CRUD operations, prefill support, timer reset, date prefill fix
|
||||
- ✅ Stats page (`src/routes/stats/+page.svelte`) - Chart.js integration (doughnut & bar charts)
|
||||
- ✅ Commanders page (`src/routes/commanders/+page.svelte`) - Full CRUD with color identity
|
||||
- ✅ Profile page (`src/routes/profile/+page.svelte`) - Password change functionality
|
||||
- ✅ Round Counter page (`src/routes/round-counter/+page.svelte`) - Timer with localStorage persistence
|
||||
|
||||
### 5. Static Assets
|
||||
- ✅ Moved all CSS files to `static/css/`
|
||||
- ✅ Moved all images to `static/images/`
|
||||
- ✅ Created `static/favicon.svg`
|
||||
- ✅ `static/version.txt` configured for deployment
|
||||
|
||||
### 6. Docker & Deployment Configuration
|
||||
- ✅ Created `Dockerfile.svelte` - Multi-stage build for production
|
||||
- ✅ Updated `docker-compose.yml` - Frontend service uses Dockerfile.svelte
|
||||
- ✅ Updated `deploy.sh` - SvelteKit build process, version.txt path updated
|
||||
- ✅ Updated `nginx.conf` - SPA routing (all routes fallback to index.html)
|
||||
- ✅ Created `SVELTE_DEPLOYMENT.md` - Complete deployment guide and testing checklist
|
||||
|
||||
### 7. Cache Busting Solution
|
||||
- ✅ **Automatic cache busting**: Vite/SvelteKit generates hashed filenames (e.g., `stats.abc123.js`)
|
||||
- ✅ No manual version injection needed
|
||||
- ✅ Users will always get the latest version without hard refresh
|
||||
|
||||
## 📝 Clean-Up Notes
|
||||
|
||||
### Old Alpine.js Files
|
||||
- The `frontend/public/` directory with Alpine.js files only exists in the `main` branch
|
||||
- The `svelte-migration` branch never included these files (clean from the start)
|
||||
- **Action Required**: When merging to `main`, decide whether to:
|
||||
1. Delete `frontend/public/` entirely (recommended)
|
||||
2. Archive it for rollback purposes
|
||||
3. Keep temporarily during transition period
|
||||
|
||||
## 🔧 Configuration Files Updated
|
||||
```dockerfile
|
||||
# Update to build SvelteKit app
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
### 2. nginx.conf for SPA
|
||||
```nginx
|
||||
# Update location / to handle SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Configuration Files Updated
|
||||
- ✅ `svelte.config.js` - SvelteKit with static adapter
|
||||
- ✅ `vite.config.js` - Dev server with API proxy to backend
|
||||
- ✅ `tailwind.config.js` - Updated content paths, converted to ESM
|
||||
- ✅ `postcss.config.js` - Converted to ESM
|
||||
- ✅ `package.json` - Scripts updated for SvelteKit
|
||||
- ✅ `nginx.conf` - Updated for SPA routing (lines 83-85)
|
||||
- ✅ `Dockerfile.svelte` - Multi-stage production build
|
||||
- ✅ `docker-compose.yml` - Frontend service configuration
|
||||
- ✅ `deploy.sh` - SvelteKit build process, version.txt path updated
|
||||
|
||||
## 🎯 Benefits Achieved
|
||||
|
||||
1. ✅ **Automatic Cache Busting** - Vite generates hashed filenames (e.g., `app.abc123.js`)
|
||||
2. ✅ **Better Code Organization** - Clean component and store structure
|
||||
3. ✅ **Type Safety Ready** - Can add TypeScript easily if needed
|
||||
4. ✅ **Hot Module Replacement** - Fast development with instant updates
|
||||
5. ✅ **Centralized Auth** - Store-based authentication with reactive state
|
||||
6. ✅ **Production Ready** - Successful build with optimized bundles (~203KB main chunk, 69.6KB gzipped)
|
||||
7. ✅ **Developer Experience** - Better tooling, error messages, and debugging
|
||||
|
||||
## 📝 Testing Checklist
|
||||
|
||||
- ✅ Login flow works
|
||||
- ✅ Registration flow works with password strength validation
|
||||
- ✅ Protected routes redirect to login correctly
|
||||
- ✅ Logout clears tokens and redirects
|
||||
- ✅ Dashboard loads user data (stats cards, recent games, top commanders)
|
||||
- ✅ Games CRUD operations work (create, read, update, delete)
|
||||
- ✅ Stats charts display correctly with Chart.js integration
|
||||
- ✅ Commanders management works with color identity selection
|
||||
- ✅ Profile password change works
|
||||
- ✅ Round counter timer works with localStorage persistence
|
||||
- ✅ Timer reset after game log works (clears localStorage)
|
||||
- ✅ Prefill from round counter to games page works
|
||||
- ✅ Edit game form date prefill works
|
||||
- ✅ API proxy works in development (localhost:5173 → localhost:3000)
|
||||
- ✅ Production build succeeds (`npm run build`)
|
||||
- ⏳ Docker build test (pending deployment)
|
||||
|
||||
## 🚀 Running the App
|
||||
|
||||
### Development
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
App runs at http://localhost:5173 with API proxy to http://localhost:3000
|
||||
|
||||
### Production Build
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
# Output in ./build directory
|
||||
```
|
||||
|
||||
### Preview Production Build
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 📦 Dependencies
|
||||
|
||||
### Runtime
|
||||
- svelte: ^5.55.2
|
||||
- @sveltejs/kit: ^2.57.1
|
||||
- chart.js: ^4.4.1 (for stats page)
|
||||
|
||||
### Build
|
||||
- @sveltejs/adapter-static: ^3.0.10
|
||||
- vite: ^8.0.8
|
||||
- tailwindcss: ^3.4.0
|
||||
- postcss: ^8.4.32
|
||||
- autoprefixer: ^10.4.16
|
||||
|
||||
## 🔍 Key Differences from Alpine.js
|
||||
|
||||
| Alpine.js | Svelte | Notes |
|
||||
|-----------|--------|-------|
|
||||
| `x-data` | `<script>` block | Component logic |
|
||||
| `x-model` | `bind:value` | Two-way binding |
|
||||
| `@click` | `on:click` | Event handling |
|
||||
| `x-show` | `{#if}` | Conditional rendering |
|
||||
| `x-for` | `{#each}` | List rendering |
|
||||
| `x-text` | `{variable}` | Text interpolation |
|
||||
| Global functions | Import from stores/utils | Better encapsulation |
|
||||
|
||||
## 💡 Development Notes
|
||||
|
||||
1. **Chart.js Integration** - Dynamically imported in stats page to avoid SSR issues
|
||||
2. **Date Format Handling** - API returns dates that need conversion to YYYY-MM-DD for HTML date inputs
|
||||
3. **Field Name Mapping** - Backend uses snake_case (e.g., `commander_id`), frontend uses camelCase (e.g., `commanderId`)
|
||||
4. **Timer Storage** - Round counter uses localStorage key `edh-round-counter-state`
|
||||
5. **Prefill Support** - Round counter can prefill games page via localStorage key `edh-prefill-game`
|
||||
|
||||
## 🐛 Known Minor Issues (Non-blocking)
|
||||
|
||||
1. Accessibility warnings (a11y) from Svelte compiler - modal click handlers and ARIA roles
|
||||
2. Tailwind darkMode configuration warning - can be safely updated to 'media'
|
||||
3. Font file warning - Beleren-Bold.ttf will resolve at runtime
|
||||
|
||||
## 📚 Resources
|
||||
|
||||
- [SvelteKit Docs](https://kit.svelte.dev/docs)
|
||||
- [Svelte Tutorial](https://svelte.dev/tutorial)
|
||||
- [Chart.js with Svelte](https://www.chartjs.org/docs/latest/getting-started/integration.html)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "edh-stats-backend",
|
||||
"version": "2.1.8",
|
||||
"version": "2.2.0",
|
||||
"description": "Backend API for EDH/Commander stats tracking application",
|
||||
"main": "src/server.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -566,8 +566,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 +647,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',
|
||||
|
||||
@@ -26,7 +26,6 @@ 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'
|
||||
@@ -50,7 +49,6 @@ 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'
|
||||
|
||||
22
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:"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ http {
|
||||
}
|
||||
|
||||
# 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";
|
||||
|
||||
1167
frontend/package-lock.json
generated
@@ -1,21 +1,27 @@
|
||||
{
|
||||
"name": "edh-stats-frontend",
|
||||
"version": "2.1.8",
|
||||
"version": "2.2.0",
|
||||
"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",
|
||||
@@ -25,5 +31,6 @@
|
||||
"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>
|
||||
@@ -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,449 +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()
|
||||
|
||||
// Reset the round counter state
|
||||
localStorage.removeItem('edh-round-counter-state')
|
||||
} 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.12
|
||||
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>
|
||||
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">
|
||||
<div class="container mx-auto px-4 py-6 text-center text-sm text-gray-600">
|
||||
<p>EDH Stats Tracker • 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}
|
||||
233
frontend/src/lib/stores/auth.js
Normal file
@@ -0,0 +1,233 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// Auth token management
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable({
|
||||
token: null,
|
||||
user: null,
|
||||
loading: true,
|
||||
allowRegistration: true
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Initialize auth store - load token from storage
|
||||
*/
|
||||
init: async () => {
|
||||
if (!browser) return;
|
||||
|
||||
const token = localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token');
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// Verify token with backend
|
||||
const response = await fetch('/api/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
update(state => ({ ...state, token, user: data.user, loading: false }));
|
||||
} else {
|
||||
// Invalid token
|
||||
localStorage.removeItem('edh-stats-token');
|
||||
sessionStorage.removeItem('edh-stats-token');
|
||||
update(state => ({ ...state, token: null, user: null, loading: false }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth init error:', error);
|
||||
update(state => ({ ...state, loading: false }));
|
||||
}
|
||||
} else {
|
||||
update(state => ({ ...state, loading: false }));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Login with username and password
|
||||
*/
|
||||
login: async (username, password, remember = false) => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password, remember })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Store token
|
||||
if (remember) {
|
||||
localStorage.setItem('edh-stats-token', data.token);
|
||||
} else {
|
||||
sessionStorage.setItem('edh-stats-token', data.token);
|
||||
}
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
token: data.token,
|
||||
user: data.user
|
||||
}));
|
||||
|
||||
return { success: true };
|
||||
} else {
|
||||
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 a new user
|
||||
*/
|
||||
register: async (username, email, password) => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
email: email || undefined,
|
||||
password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Store token
|
||||
localStorage.setItem('edh-stats-token', data.token);
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
token: data.token,
|
||||
user: data.user
|
||||
}));
|
||||
|
||||
return { success: true };
|
||||
} else {
|
||||
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 user
|
||||
*/
|
||||
logout: () => {
|
||||
if (browser) {
|
||||
localStorage.removeItem('edh-stats-token');
|
||||
sessionStorage.removeItem('edh-stats-token');
|
||||
}
|
||||
set({ token: null, user: null, loading: false, allowRegistration: true });
|
||||
goto('/login');
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the current user data in the store
|
||||
*/
|
||||
updateUser: (user) => {
|
||||
update(state => ({ ...state, user }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Check registration config
|
||||
*/
|
||||
checkRegistrationConfig: async () => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/config');
|
||||
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();
|
||||
|
||||
// Derived store for authentication status
|
||||
export const isAuthenticated = derived(
|
||||
auth,
|
||||
$auth => !!$auth.token && !!$auth.user
|
||||
);
|
||||
|
||||
// Derived store for current user
|
||||
export const currentUser = derived(
|
||||
auth,
|
||||
$auth => $auth.user
|
||||
);
|
||||
|
||||
/**
|
||||
* Get auth token from storage
|
||||
*/
|
||||
export function getAuthToken() {
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem('edh-stats-token') ||
|
||||
sessionStorage.getItem('edh-stats-token');
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated fetch wrapper
|
||||
*/
|
||||
export async function authenticatedFetch(url, options = {}) {
|
||||
const token = getAuthToken();
|
||||
|
||||
// Only set Content-Type for requests with a body
|
||||
const defaultHeaders = {
|
||||
...(options.body && { '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, clear and redirect
|
||||
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 />
|
||||
68
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { auth } from "$stores/auth";
|
||||
|
||||
let allowRegistration = true;
|
||||
|
||||
onMount(async () => {
|
||||
await auth.checkRegistrationConfig();
|
||||
auth.subscribe(($auth) => {
|
||||
allowRegistration = $auth.allowRegistration;
|
||||
});
|
||||
});
|
||||
</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>
|
||||
|
||||
<!-- Features Section -->
|
||||
<div class="mt-12 max-w-4xl mx-auto">
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<div class="card text-center">
|
||||
<div class="text-4xl mb-3">📊</div>
|
||||
<h3 class="font-bold text-lg mb-2">Track Games</h3>
|
||||
<p class="text-gray-600">Log your EDH games and commanders</p>
|
||||
</div>
|
||||
<div class="card text-center">
|
||||
<div class="text-4xl mb-3">📈</div>
|
||||
<h3 class="font-bold text-lg mb-2">View Stats</h3>
|
||||
<p class="text-gray-600">Analyze your win rates and performance</p>
|
||||
</div>
|
||||
<div class="card text-center">
|
||||
<div class="text-4xl mb-3">⏱️</div>
|
||||
<h3 class="font-bold text-lg mb-2">Round Counter</h3>
|
||||
<p class="text-gray-600">Track game duration and rounds</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
495
frontend/src/routes/commanders/+page.svelte
Normal file
@@ -0,0 +1,495 @@
|
||||
<script>
|
||||
import { onMount } 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";
|
||||
|
||||
let showAddForm = false;
|
||||
let commanders = [];
|
||||
let loading = false;
|
||||
let submitting = false;
|
||||
let serverError = "";
|
||||
let editingCommander = null;
|
||||
|
||||
let newCommander = {
|
||||
name: "",
|
||||
colors: [],
|
||||
};
|
||||
|
||||
$: formData = editingCommander || newCommander;
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadCommanders();
|
||||
});
|
||||
|
||||
async function loadCommanders() {
|
||||
loading = true;
|
||||
try {
|
||||
// Load all commanders (not just ones with stats)
|
||||
const response = await authenticatedFetch("/api/commanders");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const commandersList = data.commanders || [];
|
||||
|
||||
// Load stats for each commander
|
||||
const statsResponse = await authenticatedFetch("/api/stats/commanders");
|
||||
let statsMap = {};
|
||||
if (statsResponse.ok) {
|
||||
const statsData = await statsResponse.json();
|
||||
const statsList = statsData.stats || [];
|
||||
// Map stats by commanderId
|
||||
statsList.forEach((stat) => {
|
||||
statsMap[stat.commanderId] = stat;
|
||||
});
|
||||
}
|
||||
|
||||
// Merge commanders with their stats
|
||||
commanders = commandersList.map((cmd) => ({
|
||||
...cmd,
|
||||
commanderId: cmd.id,
|
||||
totalGames: cmd.totalGames || 0,
|
||||
winRate: cmd.winRate || 0,
|
||||
avgRounds: cmd.avgRounds || 0,
|
||||
wins: cmd.totalWins || 0,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Load commanders error:", error);
|
||||
serverError = "Failed to load commanders";
|
||||
} finally {
|
||||
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 startEdit(commander) {
|
||||
// Handle both array and string formats for colors
|
||||
const colorsArray = Array.isArray(commander.colors)
|
||||
? commander.colors
|
||||
: typeof commander.colors === "string"
|
||||
? commander.colors.split("")
|
||||
: [];
|
||||
|
||||
editingCommander = {
|
||||
id: commander.id || commander.commanderId,
|
||||
name: commander.name,
|
||||
colors: colorsArray,
|
||||
};
|
||||
showAddForm = true;
|
||||
serverError = "";
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getColorComponents(colors) {
|
||||
if (!colors || colors.length === 0) return [];
|
||||
|
||||
// Handle both string and array formats
|
||||
const colorArray = typeof colors === "string" ? colors.split("") : colors;
|
||||
|
||||
return colorArray
|
||||
.map((c) => mtgColors.find((mc) => mc.id === c))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
</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">
|
||||
<NavBar />
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<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">
|
||||
<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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Color Identity
|
||||
</label>
|
||||
<div class="flex gap-3">
|
||||
{#each mtgColors as color}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => toggleColor(color.id)}
|
||||
class="w-12 h-12 rounded-full border-2 transition-all {formData.colors.includes(
|
||||
color.id,
|
||||
)
|
||||
? 'border-gray-900 ring-2 ring-offset-2 ring-gray-900'
|
||||
: 'border-gray-300 hover:border-gray-400'}"
|
||||
style="background-color: {color.hex}"
|
||||
title={color.name}
|
||||
>
|
||||
<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 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="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each commanders as commander}
|
||||
<div class="card hover:shadow-lg transition-shadow">
|
||||
<!-- Header with name and actions -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<h3 class="text-xl font-bold text-gray-900">
|
||||
{commander.name}
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
on:click={() => startEdit(commander)}
|
||||
class="text-indigo-600 hover:text-indigo-800 text-xl font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
on:click={() =>
|
||||
showDeleteConfirm(
|
||||
commander.id || commander.commanderId,
|
||||
commander.name,
|
||||
)}
|
||||
class="text-red-600 hover:text-red-800 text-xl font-medium"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color badges -->
|
||||
<div class="flex gap-2 mb-6">
|
||||
{#each getColorComponents(commander.colors) as color}
|
||||
<div
|
||||
class="w-8 h-8 rounded"
|
||||
style="background-color: {color.hex}"
|
||||
title={color.name}
|
||||
></div>
|
||||
{:else}
|
||||
<span class="text-sm text-gray-500 italic">Colorless</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<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}
|
||||
|
||||
<!-- 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={() =>
|
||||
!deleteConfirm.deleting && (deleteConfirm.show = false)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg p-6 max-w-sm w-full mx-4"
|
||||
on:click|stopPropagation
|
||||
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>
|
||||
395
frontend/src/routes/dashboard/+page.svelte
Normal file
@@ -0,0 +1,395 @@
|
||||
<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,
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
function getColorName(color) {
|
||||
const colorNames = {
|
||||
W: "White",
|
||||
U: "Blue",
|
||||
B: "Black",
|
||||
R: "Red",
|
||||
G: "Green",
|
||||
};
|
||||
return colorNames[color] || color;
|
||||
}
|
||||
</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">
|
||||
<NavBar />
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
{#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>
|
||||
<p class="text-3xl font-bold text-gray-900">
|
||||
{stats.totalCommanders}
|
||||
</p>
|
||||
</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>
|
||||
580
frontend/src/routes/games/+page.svelte
Normal file
@@ -0,0 +1,580 @@
|
||||
<script>
|
||||
import { onMount } 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 loading = false;
|
||||
let submitting = false;
|
||||
let editingGame = null;
|
||||
let serverError = '';
|
||||
|
||||
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();
|
||||
commanders = data.commanders || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load commanders:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGames() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/games');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
games = data.games || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load games:', error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
setTimeout(() => {
|
||||
document.querySelector('form')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 100);
|
||||
} 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];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 = '';
|
||||
setTimeout(() => {
|
||||
document.querySelector('form')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, 100);
|
||||
}
|
||||
|
||||
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);
|
||||
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'
|
||||
});
|
||||
}
|
||||
</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">
|
||||
<NavBar />
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<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">
|
||||
<!-- 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}</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}
|
||||
</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>
|
||||
542
frontend/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,542 @@
|
||||
<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 loading = false;
|
||||
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">
|
||||
<NavBar />
|
||||
|
||||
<main class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<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>
|
||||
388
frontend/src/routes/register/+page.svelte
Normal file
@@ -0,0 +1,388 @@
|
||||
<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;
|
||||
|
||||
onMount(async () => {
|
||||
await auth.checkRegistrationConfig();
|
||||
auth.subscribe(($auth) => {
|
||||
allowRegistration = $auth.allowRegistration;
|
||||
});
|
||||
});
|
||||
|
||||
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 Terms of Service and Privacy Policy
|
||||
</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>
|
||||
264
frontend/src/routes/round-counter/+page.svelte
Normal file
@@ -0,0 +1,264 @@
|
||||
<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">
|
||||
<NavBar />
|
||||
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<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"
|
||||
>
|
||||
▶️ Start Counter
|
||||
</button>
|
||||
{:else if counterActive}
|
||||
<button
|
||||
on:click={stopCounter}
|
||||
class="flex-1 btn btn-secondary text-lg py-3"
|
||||
>
|
||||
⏸️ Pause
|
||||
</button>
|
||||
{:else if hasPausedGame}
|
||||
<button
|
||||
on:click={startCounter}
|
||||
class="flex-1 btn btn-primary text-lg py-3"
|
||||
>
|
||||
▶️ Resume
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
on:click={resetCounter}
|
||||
class="flex-1 btn btn-secondary text-lg py-3"
|
||||
disabled={!counterActive && !hasPausedGame}
|
||||
class:opacity-50={!counterActive && !hasPausedGame}
|
||||
>
|
||||
🔄 Reset
|
||||
</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>
|
||||
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
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 398 KiB |
1
frontend/static/version.txt
Normal file
@@ -0,0 +1 @@
|
||||
2.2.2
|
||||
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 = {
|
||||
export default {
|
||||
darkMode: false,
|
||||
content: ['./public/**/*.html', './public/**/*.js', './js/**/*.js'],
|
||||
content: [
|
||||
'./src/**/*.{html,js,svelte,ts}',
|
||||
'./public/**/*.html',
|
||||
'./public/**/*.js',
|
||||
'./js/**/*.js'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
|
||||
16
frontend/vite.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
// Use Docker service name when running in container, localhost for local dev
|
||||
target: process.env.DOCKER ? 'http://backend:3000' : 'http://localhost:3002',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||