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
This commit is contained in:
2026-04-11 10:42:46 +02:00
committed by GitHub
parent 2b69a07cf6
commit d69a14d80b
68 changed files with 5741 additions and 5851 deletions

5
.gitignore vendored
View File

@@ -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
View 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
View 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
View 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)

View File

@@ -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",

View File

@@ -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',

View File

@@ -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'

View File

@@ -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:"

View File

@@ -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
View 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"]

View File

@@ -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;"]

View 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;"]

View File

@@ -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;
}

View File

@@ -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";

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
})

View File

@@ -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)
}
}
})()

View File

@@ -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
}
})
}
})()

View File

@@ -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
}

View File

@@ -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)
})

View File

@@ -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');
}
}

View File

@@ -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)
})

View File

@@ -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)
})

View File

@@ -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)
})

View File

@@ -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);
});
});

View File

@@ -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)
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1 +0,0 @@
2.1.12

52
frontend/src/app.css Normal file
View 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
View 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>

View 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>

View 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;
}}
/>

View 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}

View 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;
}

View File

@@ -0,0 +1,2 @@
export const ssr = false;
export const prerender = true;

View 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 />

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 269 KiB

View File

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 216 KiB

View File

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 149 KiB

View File

Before

Width:  |  Height:  |  Size: 398 KiB

After

Width:  |  Height:  |  Size: 398 KiB

View File

@@ -0,0 +1 @@
2.2.2

25
frontend/svelte.config.js Normal file
View 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;

View File

@@ -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
View 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
}
}
}
});