Compare commits

...

20 Commits

Author SHA1 Message Date
e541b10f3f feat: implement pagination and query filter for commanders list
Some checks failed
Build and Publish Docker Images / Build and Push Docker Images (push) Failing after 2m15s
2026-04-13 09:19:31 +02:00
f5b0caf194 Add Terms of Service modal to registration flow
Add a modal with EDH Stats Tracker's Terms of Service and Privacy
Policy. The modal is triggered by a link next to the "I agree" checkbox.
It includes an "I Agree" button to validate acceptance and a close
button. The modal is dismissed on click outside or via the dedicated
buttons, ensuring a seamless user experience for new registrations.
2026-04-12 20:49:45 +02:00
fc309738f4 chore: bump version to 2.3.6 and improve profile form validation
Bump version to 2.3.6 in package.json files.

Enhance password validation regex to provide specific error messages for
lowercase, uppercase, and numeric requirements instead of generic
messages.

Add a delete confirmation modal to the profile page with a button that
disables if the user has typed a different username, preventing
accidental
2026-04-12 16:19:47 +02:00
983d9b1597 Add archived state for commanders across all layers (#3)
* Add archived state for commanders across all layers

* Add `tick` import and smooth scrollIntoView to commander and game edit
flows

Replace `setTimeout` with `await tick()` when scrolling forms into view.
Update `startEdit` functions in `/commanders` and `/games` to use the
new `tick` import. Replace hardcoded 100ms timeout with `tick` and
ensure `bind:this` is applied to form elements before scrolling.

* Add `tick` import and smooth scrollIntoView to commander and game edit
flows

Replace `setTimeout` with `await tick()` when scrolling forms into view.
Update `startEdit` functions in `/commanders` and `/games` to use the
new `tick` import. Replace hardcoded 100ms timeout with `tick` and
ensure `bind:this` is applied to form elements before scrolling.

* Add cache control headers for version.txt and handle HTML files
explicitly

* Refactor buttons to use icon-button component

Update Start/Stop/Reset controls to use the new `icon-button` class.
Replace text-only buttons with a consistent pattern of SVG icon followed
by a `<span>` label. Add corresponding CSS rules for `.icon-button`
and `.btn-icon` to ensure proper spacing and sizing.

* feat: change reset button icon and bump version

- Update reset button SVG to a circular arrow icon
- Decrease reset icon size to 1.15rem
- Bump version to 2.3.2 in static file

* Add support for displaying archived commanders count
Bump application version to 2.3.3
Refine round counter button disabled state and styling

* Add color icon assets and update version number

feat: Add color icon assets (W, U, B, R, G, C) and increment version to
2.3.4

* Add color icon assets and update version number

feat: Add color icon assets (W, U, B, R, G, C) and increment version to
2.3.4

* Update color assets from SVG to PNG and refresh image files

Replace all `.svg` color icons (W, U, B, R, G, C) with corresponding
`.png` files. Add new asset `timer.png` and `commanders.png`. Remove
`round_timer.png`, `stats.png`, and `logs.png`. Replace `logs.png` with
an updated version. Bump version number from 2.3.4 to 2.3.5.

* Update commander profile card styling and replace placeholder icons

```
Update commander profile card styling and replace placeholder icons

- Revert white background and padding for the main container
- Increase icon chip dimensions to 90% width and height
- Remove border and inset shadow from color chip icons
- Replace generic SVG placeholders (B, C, G, R, U, W) with new designs
  ```

* Update footer to remove version prefix and add commander images
2026-04-12 15:37:17 +02:00
de1eb635d6 Bump package versions to 2.2.5 2026-04-11 20:24:49 +02:00
76b8cb3199 feat: Complete SvelteKit frontend migration and update deployment
checklists

SVELTE_MIGRATION.md --- Text
1 # Svelte Migration Progress
2
3 ## 🎉 MIGRATION COMPLETE!
4
5 All pages have been successfully migrated from Alpine.js to SvelteKit!
The application is fully functional, tested, and ready for deployment.
6
7 **Final Build Status**:  Production build successful (tested on
2026-04-10)
8
9 ##  Completed
10
11 ### 1. Project Setup
12 -  Installed SvelteKit and dependencies (@sveltejs/kit,
@sveltejs/adapter
2026-04-11 20:17:29 +02:00
4822e03ab4 Migrate frontend from Alpine.js to SvelteKit and update documentation
Migrated the frontend from Alpine.js to SvelteKit with shared UI stores.
Updated the project structure to use SvelteKit primitives for routing,
state management, and form handling. Added a new round counter component
and updated the project root to use a single Dockerfile. Updated
dependencies including Alpine.js, Vite, and SvelteKit. Refined CSS and
configuration files for the new frontend stack.
2026-04-11 20:12:45 +02:00
2c0cd01ab2 Update version to 2.2.0 and migrate to session-based cookies
*   Bump backend package version to `2.2.0` in `package.json` and
    `package-lock.json`.
*   Replace local storage token management with secure HTTP-only
    cookies.
    *   Added cookie options to `@fastify/cookie` plugin configuration
        in `server.js` (request-time parsing, strict same-site,
        production enforcement).
    *   Updated `auth.js` routes to use `reply.setCookie` and
        `reply.clearCookie` instead of manual token handling.
    *   Added `request.headers.authorization` pre-handling hook to
        inject cookie tokens into the Authorization header for route
        handlers.
*   Updated `frontend/src/lib/stores/auth.js`:
    *   Switched token storage logic to rely solely on cookies via the
        browser (`credentials: 'include'`).
    *   Removed `localStroage` and `sessionStor`ge usage for the auth
        token.
    *   Refactored login/register flow to call `markAuthenticated()`
        immediately upon success.
    *   Updated logout to clear the backend cookie via
        `/api/auth/logout` and reset store state.
    *   Modified `checkRegistrationConfig` and other store methods to
        handle state updates correctly without local storage
        persistence.
*   Removed `localStroage` and `sessionStor`ge references from the
    frontend register page UI and validation logic.
    Update version to 2.2.0 and migrate to session-based cookies

Replace JWT token storage with HTTP-only session cookies in the backend.
Add `/session` endpoint to verify cookie-based authentication and remove
reliance on localStorage for client-side token management. Update
frontend auth store to handle cookies via `credentials: include` and
refresh tokens on 401 errors.
2026-04-11 20:08:29 +02:00
24510001b5 Refactor Commander card layout and delete dialog
- Convert `label` element to `p` tag with `text-sm` class
- Restructure color identity section from single flex to multi-column
  grid
- Add `tabindex="-1"` to dialog overlay for proper keyboard focus
  management
- Add `on:keydown` handler to dialog overlay to prevent default key
  actions
- Improve delete dialog spacing and typography
2026-04-11 19:13:35 +02:00
e3c421a52e Refactor pagination logic in game list and bump version
</think>

Refactor pagination logic in game list and bump version
2026-04-11 13:20:38 +02:00
4310d9a1c4 Update publish.yml 2026-04-11 13:01:58 +02:00
20baba9a5f Update GitHub Actions delete-packag-versions workflow
Change the package-name input from referencing environment variables
directly to using env.PR and env.PROJECT_NAME references for the backend
and frontend workflows.
2026-04-11 12:56:06 +02:00
81ea4244af Update publish.yml 2026-04-11 12:53:57 +02:00
5ca10f3b0e Fix typo in build and push frontend job name
Update context and Dockerfile path for frontend builds
2026-04-11 12:51:52 +02:00
b84ac04396 Update version to 2.2.3 and refactor commander data loading
- Fetch and map statistics for each commander in /api/stats/commanders
- Merge commander lists with their corresponding stats using commanderId
- Remove unnecessary text diff artifacts from static version file
2026-04-11 11:47:34 +02:00
def4ddabb7 Update publish.yml 2026-04-11 10:51:17 +02:00
d69a14d80b 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
2026-04-11 10:42:46 +02:00
2b69a07cf6 Update deploy.sh 2026-04-10 13:58:34 +02:00
fe9adb97fa Change cache lifetime for JS files to 1 day
- Update `nginx.prod.conf` to reduce JS file expiration from 1 year to 1
  day
- Clear `edh-round-counter-state` from `localStorage` on game load
- Bump version from 2.1.10 to 2.1.12
2026-04-10 12:31:24 +02:00
58def0c006 Filter zero-win entries and apply pastel palette 2026-02-22 22:02:51 +01:00
86 changed files with 6113 additions and 6259 deletions

View File

@@ -3,15 +3,15 @@ name: Build and Publish Docker Images
on:
push:
tags:
- 'v*'
- 'release-*'
- "v*"
- "release-*"
branches:
- main
- production
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., 1.0.0)'
description: "Version tag (e.g., 1.0.0)"
required: true
type: string
@@ -76,8 +76,8 @@ jobs:
- name: Build and push frontend image
uses: docker/build-push-action@v5
with:
context: ./
file: ./frontend/Dockerfile.prod
context: ./frontend
file: ./frontend/Dockerfile.svelte
platforms: linux/amd64
push: true
tags: |
@@ -199,22 +199,24 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Clean up old backend images
uses: actions/delete-package-versions@v4
uses: actions/delete-package-versions@v5
continue-on-error: true
with:
package-name: '${{ env.PROJECT_NAME }}-backend'
package-type: 'container'
owner: ${{ github.repository_owner }}
package-name: ${{ env.PROJECT_NAME }}-backend
package-type: container
min-versions-to-keep: 10
delete-only-untagged-versions: false
delete-only-untagged-versions: true
- name: Clean up old frontend images
uses: actions/delete-package-versions@v4
- name: Clean up old backend images
uses: actions/delete-package-versions@v5
continue-on-error: true
with:
package-name: '${{ env.PROJECT_NAME }}-frontend'
package-type: 'container'
owner: ${{ github.repository_owner }}
package-name: ${{ env.PROJECT_NAME }}-frontend
package-type: container
min-versions-to-keep: 10
delete-only-untagged-versions: false
delete-only-untagged-versions: true
- name: Post deployment info
run: |

5
.gitignore vendored
View File

@@ -98,6 +98,11 @@ Thumbs.db
dist/
build/
# SvelteKit
.svelte-kit/
frontend/.svelte-kit/
frontend/build/
# Temporary files
tmp/
temp/

View File

@@ -1,327 +0,0 @@
# EDH Stats - Deployment Checklist
## Pre-Deployment Verification
### Code Quality
- [x] No SQLite references remain
- [x] All database operations use async/await
- [x] All queries are parameterized
- [x] Error handling implemented
- [x] Transaction support working
- [x] Repository pattern applied
### Docker & Build
- [x] package-lock.json synchronized
- [x] Dockerfile using correct npm syntax
- [x] All dependencies installed
- [x] Docker build completes without errors
### Configuration
- [x] .env.example created with PostgreSQL variables
- [x] docker-compose.yml updated for PostgreSQL
- [x] deploy.sh updated for production
- [x] GitHub Actions workflow updated
### Documentation
- [x] POSTGRES_MIGRATION_COMPLETE.md created
- [x] MIGRATION_STATUS.md created
- [x] Repository pattern documented
- [x] Deployment procedures documented
---
## Development Deployment
### Prerequisites
- Docker installed
- Docker Compose installed
- Git repository cloned
### Deployment Steps
```bash
# 1. Navigate to project directory
cd /path/to/edh-stats
# 2. Start all services
docker-compose up
# 3. Wait for services to start
# Expected output:
# - postgres: "database system is ready to accept connections"
# - db-migrate: "Migrations completed successfully!"
# - backend: "Server listening on http://0.0.0.0:3000"
# - frontend: "nginx running"
# 4. Verify services are running
docker-compose ps
# 5. Test API endpoint
curl http://localhost:3002/api/health
```
### Verification
- [ ] PostgreSQL is running
- [ ] Migrations completed successfully
- [ ] Backend API is responsive
- [ ] Frontend is accessible at http://localhost:8081
- [ ] Database has test data
### Testing
```bash
# Run migrations manually
docker-compose exec backend node src/database/migrate.js migrate
# Seed sample data
docker-compose exec backend node src/database/migrate.js seed
# Query database
docker-compose exec postgres psql -U edh_user -d edh_stats
```
---
## Production Deployment
### Prerequisites
- Docker and Docker Compose installed
- GitHub Container Registry access
- GHCR token with write:packages permission
- `.env` file with production secrets
### Build & Push Images
```bash
# 1. Navigate to project directory
cd /path/to/edh-stats
# 2. Set GitHub user (if not already set)
export GITHUB_USER=your-github-username
# 3. Build and push images
./deploy.sh 1.0.0 <GHCR_TOKEN>
# Expected output:
# - Version file updated
# - Backend image built and pushed
# - Frontend image built and pushed
# - Deployment config generated
```
### Create Environment File
```bash
# 1. Create .env file
cat > .env << 'ENVEOF'
# PostgreSQL Database
DB_HOST=postgres
DB_PORT=5432
DB_NAME=edh_stats
DB_USER=edh_user
DB_PASSWORD=$(openssl rand -base64 32)
# Application
NODE_ENV=production
LOG_LEVEL=warn
# Security
JWT_SECRET=$(openssl rand -base64 32)
# CORS
CORS_ORIGIN=https://yourdomain.com
# Registration
ALLOW_REGISTRATION=false
ENVEOF
# 2. Review .env file
cat .env
# 3. Make sure passwords are secure
# The script generates random passwords above, but review them!
```
### Deploy Services
```bash
# 1. Pull latest images
docker pull ghcr.io/your-username/edh-stats-backend:1.0.0
docker pull ghcr.io/your-username/edh-stats-frontend:1.0.0
# 2. Start services
docker-compose -f docker-compose.prod.deployed.yml up -d
# 3. Monitor migrations
docker-compose logs -f db-migrate
# 4. Wait for migrations to complete
# Expected output:
# db-migrate: "Migrations completed successfully!"
# 5. Check all services are running
docker-compose ps
# 6. Verify services are healthy
docker-compose exec backend curl http://localhost:3000/api/health
```
### Post-Deployment Verification
- [ ] PostgreSQL is running and healthy
- [ ] Database migrations completed successfully
- [ ] Backend API is responding to health checks
- [ ] Frontend is accessible via reverse proxy
- [ ] SSL/TLS certificate is valid (if behind proxy)
- [ ] Application logs show no errors
- [ ] Database has expected schema
### Testing Production Deployment
```bash
# Test API health
curl https://yourdomain.com/api/health
# Check version
curl https://yourdomain.com/api/auth/config
# Monitor logs
docker-compose logs -f backend
# Database check
docker-compose exec postgres pg_isready -U edh_user
```
---
## Rollback Procedure (if needed)
### If Build Fails
```bash
# Review logs
docker-compose logs backend
# Stop services
docker-compose down
# Check Dockerfile changes
git diff HEAD~1 backend/Dockerfile
# Revert if necessary
git revert HEAD
```
### If Migration Fails
```bash
# Check migration logs
docker-compose logs db-migrate
# Review migration SQL
cat backend/src/database/migrations.sql
# Restart migration container
docker-compose restart db-migrate
# Monitor migration progress
docker-compose logs -f db-migrate
```
### If Database Issues
```bash
# Stop all services
docker-compose down
# Remove database volume (WARNING: deletes data!)
docker volume rm edh-stats_postgres_data
# Restart services
docker-compose -f docker-compose.prod.deployed.yml up -d
# Migrations will run automatically on fresh start
```
### Complete Rollback to Previous Version
```bash
# Checkout previous commit
git checkout HEAD~5
# Rebuild everything
docker-compose down -v
docker-compose build --no-cache
# Start fresh
docker-compose up -d
# Monitor startup
docker-compose logs -f
```
---
## Monitoring & Maintenance
### Regular Checks
```bash
# Daily health check
docker-compose exec backend curl http://localhost:3000/api/health
# Weekly database backup
docker exec edh-stats-postgres pg_dump -U edh_user -d edh_stats > backup-$(date +%Y%m%d).sql
# Monitor container resources
docker stats
```
### Common Issues
| Issue | Solution |
|-------|----------|
| "Connection refused" | Check PostgreSQL is running: `docker-compose ps` |
| "Migrations failed" | Review logs: `docker-compose logs db-migrate` |
| "Database is locked" | Stop and restart container: `docker-compose restart postgres` |
| "Out of memory" | Increase Docker memory limit or reduce connection pool |
| "Port already in use" | Change port in docker-compose.yml or stop conflicting service |
### Performance Monitoring
```bash
# Connection pool status
docker-compose exec postgres psql -U edh_user -d edh_stats -c "SELECT datname, count(*) FROM pg_stat_activity GROUP BY datname;"
# Slow queries (if enabled)
docker-compose exec postgres psql -U edh_user -d edh_stats -c "SELECT * FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 10;"
# Database size
docker-compose exec postgres psql -U edh_user -d edh_stats -c "SELECT pg_size_pretty(pg_database_size('edh_stats'));"
```
---
## Sign-Off
| Task | Status | Date |
|------|--------|------|
| Code Review | ✅ Complete | |
| Docker Build Test | ✅ Complete | |
| Documentation Review | ✅ Complete | |
| Development Deployment | ⬜ Pending | |
| Production Deployment | ⬜ Pending | |
| Health Verification | ⬜ Pending | |
| Performance Testing | ⬜ Pending | |
---
## Support
For issues or questions:
1. Check MIGRATION_STATUS.md
2. Review POSTGRES_MIGRATION_COMPLETE.md
3. Check docker-compose logs
4. Review Dockerfile changes
5. Verify environment configuration
---
**Last Updated**: January 17, 2026
**Version**: PostgreSQL Migration Complete
**Status**: Ready for Deployment

319
DOCKER_SETUP.md Normal file
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/)

View File

@@ -1,6 +1,6 @@
# EDH/Commander Stats Tracker
A lightweight, responsive web application for tracking Magic: The Gathering EDH/Commander games with comprehensive statistics and analytics. Built with Fastify (Node.js), PostgreSQL, and Alpine.js for optimal performance and scalability.
A lightweight, responsive web application for tracking Magic: The Gathering EDH/Commander games with comprehensive statistics and analytics. Built with Fastify (Node.js), PostgreSQL, and SvelteKit for optimal performance and scalability.
## Features
@@ -9,7 +9,7 @@ A lightweight, responsive web application for tracking Magic: The Gathering EDH/
#### Authentication & Users
- **Secure Authentication**: JWT-based login/registration system with password hashing (HS512).
- **User Profile Management**: View and edit user profile information.
- **Session Management**: Persistent authentication with localStorage/sessionStorage support.
- **Session Management**: Secure, HttpOnly cookie-based authentication for seamless sessions.
- **Configurable Registration**: Toggle user registration on/off via `ALLOW_REGISTRATION` environment variable for controlled access.
#### Commander Management
@@ -64,7 +64,7 @@ A lightweight, responsive web application for tracking Magic: The Gathering EDH/
#### User Interface
- **Responsive Design**: Mobile-friendly layout using Tailwind CSS.
- **Dark Theme**: Professional dark color scheme with proper contrast.
- **Alpine.js Components**: Lightweight, reactive UI without heavy frameworks.
- **Svelte Components**: Reactive UI powered by SvelteKit and shared stores.
- **Professional Navigation**: Easy access to all major features.
- **Accessibility**: Semantic HTML and accessible form controls.
@@ -103,7 +103,7 @@ A lightweight, responsive web application for tracking Magic: The Gathering EDH/
- **Backend**: Fastify (Node.js v20+)
- **Database**: PostgreSQL 16 with connection pooling (pg library)
- **Frontend**: Alpine.js, Tailwind CSS (CDN)
- **Frontend**: SvelteKit, Tailwind CSS
- **Visualization**: Chart.js
- **Containerization**: Docker & Docker Compose
- **Authentication**: JWT with HS512 hashing
@@ -242,15 +242,15 @@ edh-stats/
│ ├── package.json # Node.js dependencies
│ └── Dockerfile
├── frontend/
│ ├── public/
│ │ ├── css/ # Tailwind styles
│ │ ├── js/ # Alpine.js components
│ │ ── components/ # Reusable HTML components
│ ├── *.html # View files
│ │ └── round-counter.html # Live round counter (NEW)
│ ├── src/
│ │ ├── routes/ # SvelteKit pages and layouts
│ │ ├── lib/components/ # Shared UI components (NavBar, Footer, etc.)
│ │ ── lib/stores/ # Svelte stores (auth, derived state)
│ ├── static/ # Static assets (fonts, images, css)
│ ├── tailwind.config.js # Tailwind configuration
│ ├── package.json # Node.js dependencies
── Dockerfile
│ ├── vite.config.js # Vite dev/proxy configuration
── package.json # Frontend dependencies
│ └── Dockerfile* # Dev/prod Dockerfiles
├── postgres_data/ # Persisted PostgreSQL data (Docker volume)
├── docs/ # Documentation
├── FIXES.md # Detailed list of fixes applied
@@ -384,24 +384,19 @@ docker compose exec postgres psql -U postgres -d edh_stats
The application logs connection pool info at startup. To debug connection issues, set `LOG_LEVEL=debug` to see detailed connection logging.
### Frontend State Management
- Alpine.js components handle all state management
- No external state management library needed
- Components:
- `app()`: Main dashboard and page initialization
- `commanderManager()`: Commander CRUD operations
- `gameManager()`: Game logging and editing
- `roundCounterApp()`: Real-time round counter with game timing
- Authentication tokens stored in `localStorage` (persistent) or `sessionStorage` (session-only)
- Data persistence: `localStorage` for round counter state
- Dynamic content loading: Partial HTML pages loaded and inserted via loaders
- SvelteKit components manage UI state through Svelte's built-in reactivity.
- Shared state (authentication, derived data) lives in `src/lib/stores`, primarily `auth.js`.
- Authentication relies on secure HttpOnly cookies; the store only tracks user metadata and loading state.
- Persistent client data (e.g., round counter progress) is stored in `localStorage` with defensive guards for SSR.
- Routing, forms, and API interactions are handled with first-class SvelteKit primitives instead of manual DOM loaders.
### Authentication Flow
1. User registers with username and password
2. Password hashed with bcryptjs (12 rounds)
3. JWT token generated (HS512 algorithm)
4. Token stored in browser (localStorage/sessionStorage)
5. Token validated on protected routes
6. Automatic token validation on component initialization
4. Secure session cookie issued to the browser
5. Protected routes validate the JWT extracted from the cookie
6. `auth.init()` validates the cookie on app start and hydrates the user store
### Error Handling
- All API errors return appropriate HTTP status codes
@@ -448,13 +443,13 @@ The application logs connection pool info at startup. To debug connection issues
- **Registration Control**: Added `ALLOW_REGISTRATION` environment variable to toggle signup availability
- **Game API Response**: Ensured all game endpoints return complete commander information (name, colors)
- **Form Validation**: Improved notes field handling to prevent null value validation errors
- **Frontend Error Handling**: Fixed Alpine.js key binding issues in top commanders template
- **Frontend Error Handling**: Fixed legacy Alpine.js key binding issues in top commanders template prior to the Svelte migration
### Previous Session Fixes
This version includes **19+ bug fixes and improvements** addressing:
- SQL parameter mismatches and injection vulnerabilities
- Boolean type conversion issues in form submissions
- Invalid Alpine.js expressions and duplicate elements
- Invalid legacy Alpine.js expressions and duplicate elements
- Corrupted SVG paths in UI components
- Field name mismatches between frontend and backend
- Color parsing and null/undefined value handling

22
TODO.md
View File

@@ -1,22 +0,0 @@
# ToDos/Bugs to check and adjust
## frontend/public/games.html
[x] export logs to JSON via a button in the top right corner, the file should have the current date in the name i.e: edh_games_16_01_2026.json
## frontend/public/round-counter.html
[x] check if every function in round-counter.js is being used
[x] instead of Stop Game -> Pause game and do not reset the time counter
[x] End Game & Log Results -> reset the counter
[x] Rest button -> has issues to display on mobile
## frontend/public/login.html
[x] check the cookie for remembering the login credentials, how many seconds does it work?
[x] prolong the cookie to stay loged in, when using the Remember me checkbox
## backend/routes/commanders.js
[x] unable to delete commanders
[x] unable to update commanders

View File

@@ -1,14 +1,15 @@
{
"name": "edh-stats-backend",
"version": "1.1.0",
"version": "2.3.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "edh-stats-backend",
"version": "1.1.0",
"version": "2.3.6",
"license": "MIT",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
@@ -132,6 +133,26 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/@fastify/cookie": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
"integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"cookie": "^1.0.0",
"fastify-plugin": "^5.0.0"
}
},
"node_modules/@fastify/cors": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "edh-stats-backend",
"version": "2.1.8",
"version": "2.3.6",
"description": "Backend API for EDH/Commander stats tracking application",
"main": "src/server.js",
"type": "module",
@@ -15,14 +15,15 @@
"db:seed": "node src/database/seed.js"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"bcryptjs": "^2.4.3",
"pg": "^8.11.3",
"close-with-grace": "^1.2.0",
"dotenv": "^16.3.1",
"fastify": "^5.7.1",
"pg": "^8.11.3",
"pino-pretty": "^13.1.3",
"zod": "^3.22.4"
},

View File

@@ -17,12 +17,16 @@ CREATE TABLE IF NOT EXISTS commanders (
name TEXT NOT NULL CHECK(LENGTH(name) >= 2),
colors JSONB NOT NULL,
user_id INTEGER NOT NULL,
archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT valid_colors CHECK(jsonb_typeof(colors) = 'array')
);
ALTER TABLE commanders
ADD COLUMN IF NOT EXISTS archived BOOLEAN NOT NULL DEFAULT FALSE;
-- Games table with all requested statistics
CREATE TABLE IF NOT EXISTS games (
id SERIAL PRIMARY KEY,

View File

@@ -10,15 +10,15 @@ export class CommanderRepository extends Repository {
/**
* Create a new commander
*/
async createCommander(userId, name, colors) {
async createCommander(userId, name, colors, archived = false) {
try {
const result = await dbManager.query(
`
INSERT INTO ${this.tableName} (name, colors, user_id)
VALUES ($1, $2, $3)
RETURNING id, name, colors, user_id, created_at, updated_at
INSERT INTO ${this.tableName} (name, colors, user_id, archived)
VALUES ($1, $2, $3, $4)
RETURNING id, name, colors, user_id, archived, created_at, updated_at
`,
[name, colors, userId]
[name, colors, userId, archived]
)
return result.rows[0]
@@ -63,6 +63,7 @@ export class CommanderRepository extends Repository {
c.name,
c.colors,
c.user_id,
c.archived,
c.created_at,
c.updated_at,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
@@ -102,6 +103,7 @@ export class CommanderRepository extends Repository {
c.name,
c.colors,
c.user_id,
c.archived,
c.created_at,
c.updated_at,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
@@ -133,6 +135,7 @@ export class CommanderRepository extends Repository {
c.name,
c.colors,
c.user_id,
c.archived,
c.created_at,
c.updated_at,
(SELECT COUNT(*) FROM games WHERE commander_id = c.id) as total_games,
@@ -201,6 +204,12 @@ export class CommanderRepository extends Repository {
paramCount++
}
if (updateData.archived !== undefined) {
updates.push(`archived = $${paramCount}`)
values.push(updateData.archived)
paramCount++
}
if (updates.length === 0) {
throw new Error('No valid fields to update')
}
@@ -233,6 +242,22 @@ export class CommanderRepository extends Repository {
}
}
/**
* Count commanders for a user filtered by name query
*/
async countCommandersByUserIdAndQuery(userId, query) {
try {
const searchQuery = `%${query}%`
const result = await dbManager.query(
`SELECT COUNT(*) as count FROM ${this.tableName} WHERE user_id = $1 AND name ILIKE $2`,
[userId, searchQuery]
)
return parseInt(result.rows[0].count, 10) || 0
} catch (error) {
throw new Error('Failed to count commanders by query')
}
}
/**
* Find commander by name and user
*/

View File

@@ -109,6 +109,38 @@ const updateUsernameSchema = z.object({
})
})
const AUTH_COOKIE_NAME = 'edh_stats_token'
const ONE_WEEK_IN_SECONDS = 60 * 60 * 24 * 7
const secureCookies =
process.env.COOKIE_SECURE === 'true' || process.env.NODE_ENV === 'production'
function buildCookieOptions(maxAgeSeconds) {
const base = {
path: '/',
sameSite: 'strict',
httpOnly: true,
secure: secureCookies
}
if (maxAgeSeconds) {
return {
...base,
maxAge: maxAgeSeconds
}
}
return base
}
function setAuthCookie(reply, token, remember) {
const maxAge = remember ? ONE_WEEK_IN_SECONDS : undefined
reply.setCookie(AUTH_COOKIE_NAME, token, buildCookieOptions(maxAge))
}
function clearAuthCookie(reply) {
reply.clearCookie(AUTH_COOKIE_NAME, buildCookieOptions(0))
}
export default async function authRoutes(fastify, options) {
// Initialize repository
const userRepo = new UserRepository()
@@ -204,6 +236,8 @@ export default async function authRoutes(fastify, options) {
}
)
setAuthCookie(reply, token, false)
reply.code(201).send({
message: 'User registered successfully',
user: {
@@ -246,7 +280,7 @@ export default async function authRoutes(fastify, options) {
async (request, reply) => {
try {
// LAYER 1: Schema validation
const { username, password } = loginSchema.parse(request.body)
const { username, password, remember } = loginSchema.parse(request.body)
// LAYER 2: Find user (also serves as authorization check)
const user = await userRepo.findByUsername(username)
@@ -278,10 +312,12 @@ export default async function authRoutes(fastify, options) {
username: user.username
},
{
expiresIn: request.body.remember ? '7d' : '2h'
expiresIn: remember ? '7d' : '2h'
}
)
setAuthCookie(reply, token, remember)
reply.send({
message: 'Login successful',
user: {
@@ -341,6 +377,8 @@ export default async function authRoutes(fastify, options) {
}
)
setAuthCookie(reply, token, false)
reply.send({
message: 'Token refreshed successfully',
token
@@ -400,6 +438,30 @@ export default async function authRoutes(fastify, options) {
}
)
fastify.get('/session', async (request, reply) => {
try {
await request.jwtVerify()
const user = await userRepo.findById(request.user.id)
if (!user) {
clearAuthCookie(reply)
return reply.send({ authenticated: false })
}
reply.send({
authenticated: true,
user: {
id: user.id,
username: user.username,
email: user.email,
createdAt: user.created_at
}
})
} catch (error) {
reply.send({ authenticated: false })
}
})
// Update user profile
fastify.patch(
'/me',
@@ -566,8 +628,8 @@ export default async function authRoutes(fastify, options) {
request.body
)
// Verify current password
const user = await userRepo.findByUsername(request.user.username)
// Verify current password - use id (stable across username changes)
const user = await userRepo.findById(request.user.id)
if (!user) {
reply.code(404).send({
error: 'Not Found',
@@ -647,8 +709,8 @@ export default async function authRoutes(fastify, options) {
request.body
)
// Verify current password
const user = await userRepo.findByUsername(request.user.username)
// Verify current password - use id (stable across username changes)
const user = await userRepo.findById(request.user.id)
if (!user) {
reply.code(404).send({
error: 'Not Found',
@@ -737,6 +799,8 @@ export default async function authRoutes(fastify, options) {
return
}
clearAuthCookie(reply)
reply.send({
message: 'Account deleted successfully'
})
@@ -749,4 +813,9 @@ export default async function authRoutes(fastify, options) {
}
}
)
fastify.post('/logout', async (request, reply) => {
clearAuthCookie(reply)
reply.send({ message: 'Logged out' })
})
}

View File

@@ -26,11 +26,11 @@ const createCommanderSchema = z.object({
errorMap: () => ({ message: 'Colors must be an array' })
}
)
.min(1, 'Select at least one color')
.max(5, 'Maximum 5 colors allowed')
.refine((colors) => hasNoDuplicateColors(colors), {
message: 'Duplicate colors are not allowed'
})
}),
archived: z.boolean('Archived must be a boolean').optional().default(false)
})
const updateCommanderSchema = z.object({
@@ -50,12 +50,12 @@ const updateCommanderSchema = z.object({
errorMap: () => ({ message: 'Invalid color (must be W, U, B, R, or G)' })
})
)
.min(1, 'Select at least one color')
.max(5, 'Maximum 5 colors allowed')
.refine((colors) => hasNoDuplicateColors(colors), {
message: 'Duplicate colors are not allowed'
})
.optional()
.optional(),
archived: z.boolean('Archived must be a boolean').optional()
})
const commanderQuerySchema = z.object({
@@ -104,6 +104,7 @@ function transformCommander(cmd) {
name: cmd.name,
colors: cmd.colors || [],
userId: cmd.user_id,
archived: cmd.archived ?? false,
totalGames: parseInt(cmd.total_games) || 0,
totalWins: parseInt(cmd.total_wins) || 0,
winRate: cmd.win_rate ? parseFloat(cmd.win_rate) : 0,
@@ -147,14 +148,19 @@ export default async function commanderRoutes(fastify, options) {
commanders = await commanderRepo.getCommandersByUserId(userId, limit, offset, sortBy, sortOrder)
}
reply.send({
commanders: commanders.map(transformCommander),
pagination: {
total: commanders.length,
limit,
offset
}
})
const totalCount = q
? await commanderRepo.countCommandersByUserIdAndQuery(userId, q)
: await commanderRepo.countCommandersByUserId(userId)
reply.send({
commanders: commanders.map(transformCommander),
pagination: {
total: totalCount,
limit,
offset,
hasMore: offset + commanders.length < totalCount
}
})
} catch (error) {
if (error instanceof z.ZodError) {
return reply.code(400).send({
@@ -215,12 +221,13 @@ export default async function commanderRoutes(fastify, options) {
return
}
reply.send({
commander: {
...commander,
colors: commander.colors || []
}
})
reply.send({
commander: {
...commander,
colors: commander.colors || [],
archived: commander.archived ?? false
}
})
} catch (error) {
fastify.log.error('Get commander error:', error)
reply.code(500).send({
@@ -286,7 +293,8 @@ export default async function commanderRoutes(fastify, options) {
const commander = await commanderRepo.createCommander(
userId,
validatedData.name,
colorsJson
colorsJson,
validatedData.archived ?? false
)
reply.code(201).send({
@@ -350,7 +358,7 @@ export default async function commanderRoutes(fastify, options) {
// Convert colors array to JSON if provided
const updatePayload = { ...updateData }
if (updatePayload.colors) {
if (updatePayload.colors !== undefined) {
updatePayload.colors = JSON.stringify(updatePayload.colors)
}

View File

@@ -50,10 +50,21 @@ export default async function statsRoutes(fastify, options) {
[userId]
)
const inactiveCommandersResult = await dbManager.get(
`
SELECT COUNT(*) AS count
FROM commanders
WHERE user_id = $1 AND archived = TRUE
`,
[userId]
)
reply.send({
totalGames: stats?.total_games || 0,
winRate: stats?.win_rate || 0,
totalCommanders: stats?.total_commanders || 0,
inactiveCommanders:
parseInt(inactiveCommandersResult?.count, 10) || 0,
avgRounds: Math.round(stats?.avg_rounds || 0)
})
} catch (error) {

View File

@@ -4,6 +4,7 @@ import 'dotenv/config.js'
import fastify from 'fastify'
import rateLimit from '@fastify/rate-limit'
import cors from '@fastify/cors'
import cookie from '@fastify/cookie'
import jwt from '@fastify/jwt'
import closeWithGrace from 'close-with-grace'
@@ -28,6 +29,19 @@ export default async function build(opts = {}) {
// Register plugins
await app.register(cors, corsConfig)
const shouldUseSecureCookies =
process.env.COOKIE_SECURE === 'true' || process.env.NODE_ENV === 'production'
await app.register(cookie, {
hook: 'onRequest',
parseOptions: {
sameSite: 'strict',
httpOnly: true,
secure: shouldUseSecureCookies,
path: '/'
}
})
// Add request logging hook
app.addHook('onRequest', async (request, reply) => {
request.startTime = Date.now()
@@ -59,6 +73,16 @@ export default async function build(opts = {}) {
secret: jwtConfig.secret
})
app.addHook('preHandler', async (request, reply) => {
if (
!request.headers.authorization &&
request.cookies &&
request.cookies.edh_stats_token
) {
request.headers.authorization = `Bearer ${request.cookies.edh_stats_token}`
}
})
// Register global rate limiting if configured
await app.register(rateLimit, {
global: true,

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:"
@@ -480,6 +476,9 @@ main() {
# Check token
check_github_token
# Authenticate (must happen before build to allow --push)
login_to_registry
# Update version file
update_version_file
@@ -490,9 +489,6 @@ main() {
# Verify images
verify_images
# Authenticate
login_to_registry
# Push images
push_backend
push_frontend

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

@@ -77,12 +77,12 @@ http {
# Hashed JS files (cache-busted)
location ~* ^/js/.*\.(js|mjs)$ {
limit_req zone=static burst=50 nodelay;
expires 1y;
expires 1d;
add_header Cache-Control "public, immutable";
}
# Regular static files (non-hashed)
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|txt)$ {
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|woff|woff2)$ {
limit_req zone=static burst=50 nodelay;
expires 1y;
add_header Cache-Control "public, immutable";
@@ -91,8 +91,9 @@ http {
# Version file endpoint
location = /version.txt {
access_log off;
expires 1h;
add_header Cache-Control "public, max-age=3600";
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# Explicitly handle HTML files - don't fallback to index.html
@@ -117,12 +118,12 @@ http {
# Error pages
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
location = /404.html {
root /usr/share/nginx/html;
internal;
}
location = /50x.html {
root /usr/share/nginx/html;
internal;

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,35 @@
{
"name": "edh-stats-frontend",
"version": "2.1.8",
"version": "2.3.6",
"description": "Frontend for EDH/Commander stats tracking application",
"type": "module",
"scripts": {
"dev": "echo 'Static files - use nginx server' && python3 -m http.server 8080",
"build-css": "tailwindcss -i ./css/input.css -o ./css/styles.css --watch",
"build-css:prod": "tailwindcss -i ./css/input.css -o ./css/styles.css --minify"
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
},
"dependencies": {
"alpinejs": "^3.13.3",
"chart.js": "^4.4.1"
},
"devDependencies": {
"tailwindcss": "^3.4.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.57.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32"
"postcss": "^8.4.32",
"svelte": "^5.55.2",
"tailwindcss": "^3.4.0",
"vite": "^8.0.8"
},
"keywords": [
"alpinejs",
"sveltekit",
"tailwindcss",
"magic-the-gathering",
"edh",
"commander"
],
"author": "EDH Stats App",
"license": "MIT"
"license": "MIT",
"main": "postcss.config.js"
}

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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

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,446 +0,0 @@
// Game management Alpine.js component
function gameManager() {
return {
showLogForm: false,
games: [],
commanders: [],
loading: false,
submitting: false,
editSubmitting: false,
editingGame: null,
serverError: '',
// Delete confirmation modal
deleteConfirm: {
show: false,
gameId: null,
deleting: false
},
// Game form data
newGame: {
date: new Date().toISOString().split('T')[0],
commanderId: '',
playerCount: 4,
won: false,
rounds: 8,
startingPlayerWon: false,
solRingTurnOneWon: false,
notes: ''
},
// Pagination - load more pattern
pagination: {
offset: 0,
limit: 20,
hasMore: false,
isLoadingMore: false
},
// Computed form data - returns editingGame if editing, otherwise newGame
get formData() {
return this.editingGame || this.newGame
},
async init() {
await Promise.all([this.loadCommanders(), this.loadGames()])
this.loadPrefilled()
},
async reloadStats() {
try {
const token =
localStorage.getItem('edh-stats-token') ||
sessionStorage.getItem('edh-stats-token')
const response = await fetch('/api/stats/overview', {
headers: { Authorization: `Bearer ${token}` }
})
if (response.ok) {
const data = await response.json()
// Set a flag for dashboard to refresh when user navigates back
localStorage.setItem('edh-stats-dirty', 'true')
}
} catch (error) {
console.error('Failed to reload stats:', error)
}
},
loadPrefilled() {
const prefilled = localStorage.getItem('edh-prefill-game')
if (prefilled) {
try {
const data = JSON.parse(prefilled)
// Populate the form with prefilled values
this.newGame.date =
data.date || new Date().toISOString().split('T')[0]
this.newGame.rounds = data.rounds || 8
this.newGame.notes =
`Ended after ${data.rounds} rounds in ${data.duration}.\nAverage time/round: ${data.avgTimePerRound}` ||
''
// Show the form automatically
this.showLogForm = true
// Clear the prefilled data from localStorage
localStorage.removeItem('edh-prefill-game')
// Scroll to the form
setTimeout(() => {
document
.querySelector('form')
?.scrollIntoView({ behavior: 'smooth' })
}, 100)
} catch (error) {
console.error('Error loading prefilled game data:', error)
}
}
},
async loadCommanders() {
try {
const response = await fetch('/api/commanders', {
headers: {
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
}
})
if (response.ok) {
const data = await response.json()
this.commanders = data.commanders || []
}
} catch (error) {
console.error('Load commanders error:', error)
}
},
async loadGames() {
this.loading = true
this.serverError = ''
try {
const response = await fetch(
`/api/games?limit=${this.pagination.limit}&offset=${this.pagination.offset}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
}
}
)
if (response.ok) {
const data = await response.json()
this.games = data.games || []
this.pagination.hasMore = data.pagination?.hasMore || false
} else {
this.serverError = 'Failed to load games'
}
} catch (error) {
console.error('Load games error:', error)
this.serverError = 'Network error occurred'
} finally {
this.loading = false
}
},
async loadMore() {
this.pagination.isLoadingMore = true
this.serverError = ''
try {
this.pagination.offset += this.pagination.limit
const response = await fetch(
`/api/games?limit=${this.pagination.limit}&offset=${this.pagination.offset}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
}
}
)
if (response.ok) {
const data = await response.json()
this.games = [...this.games, ...(data.games || [])]
this.pagination.hasMore = data.pagination?.hasMore || false
} else {
this.serverError = 'Failed to load more games'
// Revert offset on error
this.pagination.offset -= this.pagination.limit
}
} catch (error) {
console.error('Load more games error:', error)
this.serverError = 'Network error occurred'
// Revert offset on error
this.pagination.offset -= this.pagination.limit
} finally {
this.pagination.isLoadingMore = false
}
},
async handleLogGame() {
this.serverError = ''
// Basic validation
if (!this.formData.commanderId) {
this.serverError = 'Please select a commander'
return
}
if (this.editingGame) {
await this.handleUpdateGame()
} else {
await this.handleCreateGame()
}
},
async handleCreateGame() {
this.submitting = true
try {
// Ensure boolean values are actual booleans, not strings
const payload = {
date: this.newGame.date,
commanderId: parseInt(this.newGame.commanderId),
playerCount: parseInt(this.newGame.playerCount),
rounds: parseInt(this.newGame.rounds),
won: this.newGame.won === true || this.newGame.won === 'true',
startingPlayerWon:
this.newGame.startingPlayerWon === true ||
this.newGame.startingPlayerWon === 'true',
solRingTurnOneWon:
this.newGame.solRingTurnOneWon === true ||
this.newGame.solRingTurnOneWon === 'true'
}
// Only include notes if it's not empty
if (this.newGame.notes && this.newGame.notes.trim()) {
payload.notes = this.newGame.notes
}
const response = await fetch('/api/games', {
method: 'POST',
headers: {
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
if (response.ok) {
const data = await response.json()
this.games.unshift(data.game)
this.resetForm()
this.showLogForm = false
await this.reloadStats()
} else {
const errorData = await response.json()
this.serverError = errorData.message || 'Failed to log game'
}
} catch (error) {
console.error('Log game error:', error)
this.serverError = 'Network error occurred'
} finally {
this.submitting = false
}
},
async handleUpdateGame() {
this.editSubmitting = true
try {
const payload = {
date: this.editingGame.date,
commanderId: parseInt(this.editingGame.commanderId),
playerCount: parseInt(this.editingGame.playerCount),
rounds: parseInt(this.editingGame.rounds),
won: this.editingGame.won === true || this.editingGame.won === 'true',
startingPlayerWon:
this.editingGame.startingPlayerWon === true ||
this.editingGame.startingPlayerWon === 'true',
solRingTurnOneWon:
this.editingGame.solRingTurnOneWon === true ||
this.editingGame.solRingTurnOneWon === 'true'
}
// Only include notes if it's not empty
if (this.editingGame.notes && this.editingGame.notes.trim()) {
payload.notes = this.editingGame.notes
}
const response = await fetch(`/api/games/${this.editingGame.id}`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
if (response.ok) {
const data = await response.json()
const index = this.games.findIndex(
(g) => g.id === this.editingGame.id
)
if (index !== -1) {
this.games[index] = data.game
}
this.cancelEdit()
await this.reloadStats()
} else {
const errorData = await response.json()
this.serverError = errorData.message || 'Failed to update game'
}
} catch (error) {
console.error('Update game error:', error)
this.serverError = 'Network error occurred'
} finally {
this.editSubmitting = false
}
},
editGame(gameId) {
const game = this.games.find((g) => g.id === gameId)
if (game) {
// Convert date from MM/DD/YYYY to YYYY-MM-DD format for input type="date"
let dateForInput = game.date
if (dateForInput && dateForInput.includes('/')) {
const [month, day, year] = dateForInput.split('/')
dateForInput = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
this.editingGame = {
id: game.id,
date: dateForInput,
commanderId: game.commanderId,
playerCount: game.playerCount,
won: game.won === 1 || game.won === true,
rounds: game.rounds,
startingPlayerWon:
game.startingPlayerWon === 1 || game.startingPlayerWon === true,
solRingTurnOneWon:
game.solRingTurnOneWon === 1 || game.solRingTurnOneWon === true,
notes: game.notes
}
this.showLogForm = true
this.serverError = ''
setTimeout(() => {
document.querySelector('form')?.scrollIntoView({ behavior: 'smooth' })
}, 100)
}
},
cancelEdit() {
this.editingGame = null
this.resetForm()
this.showLogForm = false
},
deleteGame(gameId) {
this.deleteConfirm.gameId = gameId
this.deleteConfirm.show = true
},
async confirmDelete() {
const gameId = this.deleteConfirm.gameId
if (!gameId) return
this.deleteConfirm.deleting = true
try {
const response = await fetch(`/api/games/${gameId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')}`
}
})
if (response.ok) {
this.games = this.games.filter((g) => g.id !== gameId)
this.deleteConfirm.show = false
this.deleteConfirm.gameId = null
await this.reloadStats()
} else {
this.serverError = 'Failed to delete game'
}
} catch (error) {
console.error('Delete game error:', error)
this.serverError = 'Network error occurred while deleting'
} finally {
this.deleteConfirm.deleting = false
}
},
resetForm() {
this.newGame = {
date: new Date().toISOString().split('T')[0],
commanderId: '',
playerCount: 4,
won: false,
rounds: 8,
startingPlayerWon: false,
solRingTurnOneWon: false,
notes: ''
}
this.editingGame = null
this.serverError = ''
},
getCommanderName(id) {
const commander = this.commanders.find((c) => c.id === id)
return commander ? commander.name : 'Unknown Commander'
},
formatDate(dateString) {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
},
async exportGames() {
try {
const token = localStorage.getItem('edh-stats-token') || sessionStorage.getItem('edh-stats-token')
const response = await fetch('/api/games/export', {
headers: {
Authorization: `Bearer ${token}`
}
})
if (!response.ok) {
throw new Error('Export failed')
}
// Generate filename with current date
const today = new Date().toLocaleDateString('en-US').replace(/\//g, '_')
const filename = `edh_games_${today}.json`
// Create blob and download
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
console.error('Export failed:', error)
// Show error message to user
this.serverError = 'Failed to export games. Please try again.'
setTimeout(() => {
this.serverError = ''
}, 5000)
}
}
}
}
document.addEventListener('alpine:init', () => {
Alpine.data('gameManager', gameManager)
})

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,130 +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()
// Color Identity Win Rate Chart
const colorCtx = document
.getElementById('colorWinRateChart')
.getContext('2d')
this.charts.colorWinRate = new Chart(colorCtx, {
type: 'doughnut',
data: {
labels: chartData?.colors?.labels || [],
datasets: [
{
data: chartData?.colors?.data || [],
backgroundColor: [
'#F0E6D2', // White
'#0E68AB', // Blue
'#2C2B2D', // Black
'#C44536', // Red
'#5A7A3B' // Green
],
borderWidth: 1
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'right' }
}
}
})
// Player Count Win Rate Chart
const playerCtx = document
.getElementById('playerCountChart')
.getContext('2d')
this.charts.playerCount = new Chart(playerCtx, {
type: 'bar',
data: {
labels: chartData?.playerCounts?.labels || [],
datasets: [
{
label: 'Win Rate (%)',
data: chartData?.playerCounts?.data || [],
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.8

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,83 @@
<script>
export let commanders = [];
export let archived = false;
export let getColorIcons;
export let formatDate;
export let onEdit = () => {};
export let onDelete = () => {};
</script>
{#if commanders.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{#each commanders as commander}
<div
class="card hover:shadow-lg transition-shadow {archived ? 'opacity-80' : ''}"
>
<div class="flex items-start justify-between mb-4">
<div>
<div class="flex items-center gap-2">
<h3 class="text-xl font-bold text-gray-900">{commander.name}</h3>
{#if archived}
<span
class="inline-flex items-center rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-700"
>
Archived
</span>
{/if}
</div>
</div>
<div class="flex gap-2">
<button
on:click={() => onEdit(commander)}
class="text-indigo-600 hover:text-indigo-800 text-xl font-medium opacity-100"
>
Edit
</button>
<button
on:click={() => onDelete(commander)}
class="text-red-600 hover:text-red-800 text-xl font-medium opacity-100"
>
Delete
</button>
</div>
</div>
<div class="color-pill">
{#each getColorIcons(commander.colors) as icon}
<img
src={icon.src}
alt={`${icon.id} color icon`}
class="color-icon"
loading="lazy"
/>
{/each}
</div>
<div class="grid grid-cols-2 gap-6">
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">
{commander.totalGames || 0}
</div>
<div class="text-sm text-gray-600 mt-1">Games Played</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">
{Number(commander.winRate || 0).toFixed(1)}%
</div>
<div class="text-sm text-gray-600 mt-1">Win Rate</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-gray-900">
{Number(commander.avgRounds || 0).toFixed(1)}
</div>
<div class="text-sm text-gray-600 mt-1">Avg Rounds</div>
</div>
<div class="text-center">
<div class="text-sm text-gray-500 mt-2">Added</div>
<div class="text-sm text-gray-700">{formatDate(commander.createdAt)}</div>
</div>
</div>
</div>
{/each}
</div>
{/if}

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 w-full">
<div class="container mx-auto px-4 py-6 text-center text-sm text-gray-600">
<p>EDH Stats • Track your Commander games</p>
{#if version}
<p class="text-xs text-gray-500 mt-1">v{version}</p>
{/if}
</div>
</footer>

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,185 @@
import { writable, derived } from 'svelte/store'
import { browser } from '$app/environment'
import { goto } from '$app/navigation'
const initialState = {
token: null,
user: null,
loading: true,
allowRegistration: true
}
function createAuthStore() {
const { subscribe, update } = writable(initialState)
const markAuthenticated = (user) => {
update((state) => ({ ...state, token: 'cookie', user }))
}
return {
subscribe,
init: async () => {
if (!browser) return
try {
const response = await fetch('/api/auth/session', {
credentials: 'include',
cache: 'no-store'
})
if (!response.ok) {
update((state) => ({ ...state, token: null, user: null, loading: false }))
return
}
const data = await response.json()
if (data.authenticated) {
update((state) => ({
...state,
token: 'cookie',
user: data.user,
loading: false
}))
} else {
update((state) => ({ ...state, token: null, user: null, loading: false }))
}
} catch (error) {
console.error('Auth init error:', error)
update((state) => ({ ...state, loading: false }))
}
},
login: async (username, password, remember = false) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password, remember })
})
const data = await response.json()
if (response.ok) {
markAuthenticated(data.user)
return { success: true }
}
return {
success: false,
error: data.message || 'Login failed'
}
} catch (error) {
console.error('Login error:', error)
return {
success: false,
error: 'Network error. Please try again.'
}
}
},
register: async (username, email, password) => {
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username,
email: email || undefined,
password
})
})
const data = await response.json()
if (response.ok) {
markAuthenticated(data.user)
return { success: true }
}
let errorMessage = data.message || 'Registration failed'
if (data.details && Array.isArray(data.details)) {
errorMessage = data.details.join(', ')
}
return {
success: false,
error: errorMessage
}
} catch (error) {
console.error('Registration error:', error)
return {
success: false,
error: 'Network error. Please try again.'
}
}
},
logout: async () => {
if (browser) {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
})
} catch (error) {
console.error('Logout error:', error)
}
}
update((state) => ({ ...state, token: null, user: null, loading: false }))
goto('/login')
},
updateUser: (user) => {
update((state) => ({ ...state, user }))
},
checkRegistrationConfig: async () => {
try {
const response = await fetch('/api/auth/config', {
credentials: 'include'
})
if (response.ok) {
const data = await response.json()
update((state) => ({ ...state, allowRegistration: data.allowRegistration }))
}
} catch (error) {
console.error('Failed to check registration config:', error)
}
}
}
}
export const auth = createAuthStore()
export const isAuthenticated = derived(auth, ($auth) => !!$auth.token && !!$auth.user)
export const currentUser = derived(auth, ($auth) => $auth.user)
export async function authenticatedFetch(url, options = {}) {
const shouldSetJsonHeader =
options.body !== undefined &&
(typeof options.body === 'string' || options.body instanceof String)
const defaultHeaders = {
...(shouldSetJsonHeader && { 'Content-Type': 'application/json' })
}
const response = await fetch(url, {
credentials: 'include',
...options,
headers: {
...defaultHeaders,
...options.headers
}
})
if (response.status === 401) {
await auth.logout()
throw new Error('Authentication required')
}
return response
}

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,162 @@
<script>
import { onMount } from "svelte";
import { auth } from "$stores/auth";
let allowRegistration = true;
let activeSlide = 0;
let slideshowInterval;
const slides = [
{ src: "/images/commanders.png", alt: "Commanders management screenshot" },
{ src: "/images/logs.png", alt: "Game log screenshot" },
{ src: "/images/stats.png", alt: "Statistics dashboard screenshot" },
{ src: "/images/timer.png", alt: "Round timer screenshot" },
];
function nextSlide() {
activeSlide = (activeSlide + 1) % slides.length;
}
function goToSlide(index) {
const newIndex = (index + slides.length) % slides.length;
activeSlide = newIndex;
resetInterval();
}
function resetInterval() {
clearInterval(slideshowInterval);
slideshowInterval = setInterval(nextSlide, 6000);
}
onMount(() => {
(async () => {
await auth.checkRegistrationConfig();
})();
const unsubscribe = auth.subscribe(($auth) => {
allowRegistration = $auth.allowRegistration;
});
slideshowInterval = setInterval(nextSlide, 6000);
return () => {
unsubscribe();
clearInterval(slideshowInterval);
};
});
</script>
<svelte:head>
<title>EDH Stats Tracker</title>
<meta
name="description"
content="Track your Magic: The Gathering EDH/Commander games and statistics"
/>
</svelte:head>
<div class="min-h-full flex flex-col py-12 px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-center flex-1">
<div class="w-full space-y-8 text-center">
<div class="max-w-md mx-auto">
<h1 class="text-4xl font-bold font-mtg text-edh-primary mb-4">
EDH Stats
</h1>
<p class="text-xl text-gray-600 mb-8">
Track your Commander games and statistics
</p>
<div class="space-y-4">
<a href="/login" class="btn btn-primary w-full block"
>Login to Track Games</a
>
{#if allowRegistration}
<a href="/register" class="btn btn-secondary w-full block"
>Create New Account</a
>
{/if}
</div>
</div>
<!-- Slideshow -->
<div class="mt-12 max-w-4xl mx-auto">
<div class="carousel">
{#each slides as slide, index}
<figure
class="carousel-slide {index === activeSlide ? 'is-active' : ''}"
>
<img src={slide.src} alt={slide.alt} loading="lazy" />
</figure>
{/each}
<div class="carousel-dots">
{#each slides as _, dotIndex}
<button
class:active-dot={dotIndex === activeSlide}
aria-label={`Go to slide ${dotIndex + 1}`}
on:click={() => goToSlide(dotIndex)}
></button>
{/each}
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.carousel {
position: relative;
overflow: hidden;
border-radius: 1rem;
box-shadow: 0 10px 25px rgba(15, 23, 42, 0.15);
max-width: 70%;
margin-left: auto;
margin-right: auto;
}
.carousel-slide {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 700ms ease;
margin: 0;
pointer-events: none;
}
.carousel-slide.is-active {
opacity: 1;
position: relative;
}
.carousel-slide img {
width: 100%;
display: block;
}
.carousel-dots {
display: flex;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 0;
}
.carousel-dots button {
width: 0.7rem;
height: 0.7rem;
border-radius: 9999px;
border: none;
background: #d1d5db;
}
.carousel-dots button.active-dot {
background: #4338ca;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,636 @@
<script>
import { onMount, tick } from "svelte";
import { authenticatedFetch } from "$stores/auth";
import NavBar from "$components/NavBar.svelte";
import ProtectedRoute from "$components/ProtectedRoute.svelte";
import Footer from "$components/Footer.svelte";
import CommanderListSection from "$components/CommanderListSection.svelte";
let showAddForm = false;
let commanders = [];
let loading = false;
let loadingMore = false;
let submitting = false;
let serverError = "";
let editingCommander = null;
let formElement;
let limit = 20;
let offset = 0;
let hasMore = false;
let newCommander = {
name: "",
colors: [],
archived: false,
};
$: formData = editingCommander || newCommander;
$: activeCommanders = commanders.filter((cmd) => !cmd.archived);
$: archivedCommanders = commanders.filter((cmd) => cmd.archived);
const mtgColors = [
{ id: "W", name: "White", hex: "#F0E6D2" },
{ id: "U", name: "Blue", hex: "#0E68AB" },
{ id: "B", name: "Black", hex: "#2C2B2D" },
{ id: "R", name: "Red", hex: "#C44536" },
{ id: "G", name: "Green", hex: "#5A7A3B" },
];
const colorIconMap = {
W: "/images/W.png",
U: "/images/U.png",
B: "/images/B.png",
R: "/images/R.png",
G: "/images/G.png",
C: "/images/C.png",
};
onMount(async () => {
await loadCommanders();
});
async function loadCommanders({ append = false } = {}) {
if (append) {
if (loadingMore || !hasMore) return;
loadingMore = true;
} else {
loading = true;
offset = 0;
hasMore = false;
}
try {
// Load commanders with pagination
const queryOffset = append ? offset : 0;
const params = new URLSearchParams({
limit: limit.toString(),
offset: queryOffset.toString(),
});
const response = await authenticatedFetch(
`/api/commanders?${params.toString()}`,
);
if (response.ok) {
const data = await response.json();
const commandersList = data.commanders || [];
const mappedCommanders = commandersList.map((cmd) => ({
...cmd,
commanderId: cmd.id,
totalGames: cmd.totalGames || 0,
winRate: cmd.winRate || 0,
avgRounds: cmd.avgRounds || 0,
wins: cmd.totalWins || 0,
archived: cmd.archived ?? false,
}));
if (append) {
const existingIds = new Set(
commanders.map((cmd) => cmd.id || cmd.commanderId),
);
const deduped = mappedCommanders.filter(
(cmd) => !existingIds.has(cmd.id || cmd.commanderId),
);
commanders = [...commanders, ...deduped];
} else {
commanders = mappedCommanders;
}
hasMore = data.pagination?.hasMore ?? false;
offset = queryOffset + commandersList.length;
}
} catch (error) {
console.error("Load commanders error:", error);
serverError = "Failed to load commanders";
} finally {
if (append) {
loadingMore = false;
} else {
loading = false;
}
}
}
function toggleColor(colorId) {
const current = editingCommander || newCommander;
if (current.colors.includes(colorId)) {
current.colors = current.colors.filter((c) => c !== colorId);
} else {
current.colors = [...current.colors, colorId];
}
if (editingCommander) {
editingCommander = { ...editingCommander, colors: current.colors };
} else {
newCommander = { ...newCommander, colors: current.colors };
}
}
function normalizeColorsInput(colors) {
if (!colors) return [];
if (Array.isArray(colors)) return colors;
if (typeof colors === "string") {
return colors.split("").map((c) => c.toUpperCase());
}
return [];
}
async function startEdit(commander) {
// Handle both array and string formats for colors
const colorsArray = normalizeColorsInput(commander.colors);
editingCommander = {
id: commander.id || commander.commanderId,
name: commander.name,
colors: colorsArray,
archived: commander.archived ?? false,
};
showAddForm = true;
serverError = "";
await tick();
formElement?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function cancelEdit() {
editingCommander = null;
showAddForm = false;
resetForm();
}
async function handleAddCommander(e) {
e.preventDefault();
serverError = "";
const current = editingCommander || newCommander;
if (!current.name.trim()) {
serverError = "Commander name is required";
return;
}
submitting = true;
try {
if (editingCommander) {
// Update existing commander
const response = await authenticatedFetch(
`/api/commanders/${editingCommander.id}`,
{
method: "PUT",
body: JSON.stringify({
name: current.name.trim(),
colors: current.colors,
archived: current.archived ?? false,
}),
},
);
if (response.ok) {
await loadCommanders();
editingCommander = null;
resetForm();
showAddForm = false;
} else {
const errorData = await response.json();
serverError = errorData.message || "Failed to update commander";
}
} else {
// Create new commander
const response = await authenticatedFetch("/api/commanders", {
method: "POST",
body: JSON.stringify({
name: current.name.trim(),
colors: current.colors,
archived: current.archived ?? false,
}),
});
if (response.ok) {
await loadCommanders();
resetForm();
showAddForm = false;
} else {
const errorData = await response.json();
serverError = errorData.message || "Failed to add commander";
}
}
} catch (error) {
console.error("Commander save error:", error);
serverError = "Network error occurred";
} finally {
submitting = false;
}
}
function resetForm() {
newCommander = {
name: "",
colors: [],
archived: false,
};
}
function getColorIcons(colors) {
const normalized = normalizeColorsInput(colors);
const list = normalized.length > 0 ? normalized : ["C"];
return list
.map((colorId) => ({
id: colorId,
src: colorIconMap[colorId] || null,
}))
.filter((icon) => Boolean(icon.src));
}
function formatDate(dateString) {
if (!dateString) return "N/A";
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return "N/A";
}
}
let deleteConfirm = {
show: false,
commanderId: null,
commanderName: "",
deleting: false,
};
function showDeleteConfirm(commanderId, commanderName) {
deleteConfirm = {
show: true,
commanderId,
commanderName,
deleting: false,
};
}
function closeDeleteDialog() {
if (!deleteConfirm.deleting) {
deleteConfirm.show = false;
}
}
function handleOverlayClick(event) {
if (event.target === event.currentTarget) {
closeDeleteDialog();
}
}
function handleDeleteDialogKeydown(event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
closeDeleteDialog();
}
}
async function handleDelete() {
deleteConfirm.deleting = true;
try {
const response = await authenticatedFetch(
`/api/commanders/${deleteConfirm.commanderId}`,
{
method: "DELETE",
},
);
if (response.ok) {
await loadCommanders();
deleteConfirm.show = false;
} else {
const errorData = await response.json();
serverError = errorData.message || "Failed to delete commander";
}
} catch (error) {
console.error("Delete commander error:", error);
serverError = "Network error occurred";
} finally {
deleteConfirm.deleting = false;
}
}
async function loadMoreCommanders() {
await loadCommanders({ append: true });
}
</script>
<svelte:head>
<title>Commanders - EDH Stats Tracker</title>
<meta name="description" content="Manage your EDH/Commander decks" />
</svelte:head>
<ProtectedRoute>
<div class="min-h-screen bg-gray-50 flex flex-col">
<NavBar />
<main class="container mx-auto px-4 py-8 flex-1">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Commanders</h1>
<button
on:click={() => {
if (showAddForm) {
cancelEdit();
} else {
showAddForm = true;
}
}}
class="btn btn-primary"
>
{#if showAddForm}
Cancel
{:else}
Add Commander
{/if}
</button>
</div>
<!-- Add Commander Form -->
{#if showAddForm}
<div class="card mb-8">
<h2 class="text-xl font-bold mb-4">
{editingCommander ? "Edit Commander" : "Add New Commander"}
</h2>
<form
on:submit={handleAddCommander}
class="space-y-4"
bind:this={formElement}
>
<div>
<label
for="name"
class="block text-sm font-medium text-gray-700 mb-1"
>
Commander Name *
</label>
<input
id="name"
type="text"
bind:value={formData.name}
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Enter commander name"
/>
</div>
<div>
<p class="block text-sm font-medium text-gray-700 mb-2">
Color Identity
</p>
<div class="flex gap-3">
{#each mtgColors as color}
<button
type="button"
on:click={() => toggleColor(color.id)}
class="color-chip-button {formData.colors.includes(color.id)
? 'border-gray-900 ring-2 ring-offset-2 ring-gray-900'
: 'border-gray-300 hover:border-gray-400'}"
title={color.name}
>
<img
src={colorIconMap[color.id]}
alt=""
class="color-chip-icon"
aria-hidden="true"
loading="lazy"
/>
<span class="sr-only">{color.name}</span>
</button>
{/each}
</div>
<p class="text-xs text-gray-500 mt-2">
Leave empty for colorless commanders
</p>
</div>
{#if editingCommander}
<div class="rounded-md border border-gray-200 bg-gray-50 p-3">
<label class="flex items-start gap-3 text-sm text-gray-900">
<input
type="checkbox"
bind:checked={formData.archived}
class="mt-1 h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<span>
Archive this commander
<span class="block text-xs text-gray-500">
Archived commanders stay visible in this list but cannot
be selected when logging games.
</span>
</span>
</label>
</div>
{/if}
{#if serverError}
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm font-medium text-red-800">{serverError}</p>
</div>
{/if}
<div class="flex gap-3">
<button
type="submit"
disabled={submitting}
class="btn btn-primary disabled:opacity-50 flex-1"
>
{#if submitting}
<div class="loading-spinner w-5 h-5 mx-auto"></div>
{:else}
{editingCommander ? "Update Commander" : "Add Commander"}
{/if}
</button>
{#if editingCommander}
<button
type="button"
on:click={cancelEdit}
disabled={submitting}
class="btn bg-gray-200 hover:bg-gray-300 text-gray-700 disabled:opacity-50"
>
Cancel
</button>
{/if}
</div>
</form>
</div>
{/if}
<!-- Commanders List -->
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="loading-spinner w-12 h-12"></div>
</div>
{:else if commanders.length === 0}
<div class="card text-center py-12">
<p class="text-gray-600 mb-4">No commanders yet</p>
<button on:click={() => (showAddForm = true)} class="btn btn-primary">
Add Your First Commander
</button>
</div>
{:else}
<div class="space-y-12">
{#if activeCommanders.length > 0}
<CommanderListSection
commanders={activeCommanders}
{getColorIcons}
{formatDate}
onEdit={startEdit}
onDelete={(commander) =>
showDeleteConfirm(
commander.id || commander.commanderId,
commander.name,
)}
/>
{/if}
{#if archivedCommanders.length > 0}
<div class="space-y-4">
<div
class="rounded-md border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800"
>
Archived commanders remain in your history but will not appear
when logging new games.
</div>
<CommanderListSection
commanders={archivedCommanders}
archived={true}
{getColorIcons}
{formatDate}
onEdit={startEdit}
onDelete={(commander) =>
showDeleteConfirm(
commander.id || commander.commanderId,
commander.name,
)}
/>
</div>
{/if}
{#if hasMore}
<div class="flex justify-center pt-6">
<button
on:click={loadMoreCommanders}
class="btn btn-secondary"
disabled={loadingMore}
>
{#if loadingMore}
<div class="loading-spinner w-5 h-5"></div>
{:else}
Load More
{/if}
</button>
</div>
{:else if hasMore && commanders.length > 0}
<p class="text-center text-gray-500 pt-6">
You have reached the end of the line.
</p>
{/if}
</div>
{/if}
<!-- Delete Confirmation Modal -->
{#if deleteConfirm.show}
<div
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50"
on:click={handleOverlayClick}
on:keydown={handleDeleteDialogKeydown}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div
class="bg-white rounded-lg p-6 max-w-sm w-full mx-4"
role="document"
>
<h3 class="text-lg font-bold text-gray-900 mb-2">
Delete Commander
</h3>
<p class="text-gray-600 mb-6">
Are you sure you want to delete "{deleteConfirm.commanderName}"?
This action cannot be undone.
</p>
<div class="flex gap-3 justify-end">
<button
on:click={() => (deleteConfirm.show = false)}
disabled={deleteConfirm.deleting}
class="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50"
>
Cancel
</button>
<button
on:click={handleDelete}
disabled={deleteConfirm.deleting}
class="px-4 py-2 text-white bg-red-600 rounded-md hover:bg-red-700 disabled:opacity-50"
>
{#if deleteConfirm.deleting}
Deleting...
{:else}
Delete
{/if}
</button>
</div>
</div>
</div>
{/if}
</main>
<Footer />
</div>
</ProtectedRoute>
<style>
:global(.color-icon) {
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
object-fit: cover;
padding: 0;
background: transparent;
background-color: #fff;
padding: 0.2rem;
border: none;
box-shadow: none;
}
:global(.color-pill) {
display: inline-flex;
gap: 0.35rem;
align-items: center;
background-color: #f5f5f5;
border: 1px solid #e5e7eb;
border-radius: 9999px;
padding: 0.3rem 0.6rem;
width: fit-content;
}
.color-chip-icon {
width: 90%;
height: 90%;
border-radius: 9999px;
object-fit: cover;
pointer-events: none;
background-color: #fff;
/*border: 1px solid #e5e7eb;*/
padding: 0.2rem;
/*box-shadow: inset 0 1px 1px rgba(15, 23, 42, 0.12);*/
}
.color-chip-button {
width: 3rem;
height: 3rem;
border-radius: 9999px;
border-width: 2px;
display: inline-flex;
align-items: center;
justify-content: center;
background-color: #f3f4f6;
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08);
transition: all 0.2s ease;
padding: 0;
}
</style>

View File

@@ -0,0 +1,392 @@
<script>
import { onMount, onDestroy } from "svelte";
import { browser } from "$app/environment";
import { authenticatedFetch } from "$stores/auth";
import NavBar from "$components/NavBar.svelte";
import ProtectedRoute from "$components/ProtectedRoute.svelte";
import Footer from "$components/Footer.svelte";
let Chart;
let stats = {
totalGames: 0,
winRate: 0,
totalCommanders: 0,
inactiveCommanders: 0,
avgRounds: 0,
};
let recentGames = [];
let topCommanders = [];
let loading = true;
let charts = {};
onMount(async () => {
if (browser) {
// Dynamically import Chart.js
const ChartModule = await import("chart.js/auto");
Chart = ChartModule.default;
}
await loadDashboardData();
});
onDestroy(() => {
// Clean up charts
if (charts.colorWinRate) charts.colorWinRate.destroy();
if (charts.playerCount) charts.playerCount.destroy();
});
async function loadDashboardData() {
loading = true;
try {
// Load user stats
const statsResponse = await authenticatedFetch("/api/stats/overview");
if (statsResponse.ok) {
stats = await statsResponse.json();
}
// Load recent games
const gamesResponse = await authenticatedFetch("/api/games?limit=5");
if (gamesResponse.ok) {
const gamesData = await gamesResponse.json();
recentGames = gamesData.games || [];
}
// Load top commanders and chart data
const commandersResponse = await authenticatedFetch(
"/api/stats/commanders",
);
if (commandersResponse.ok) {
const commandersData = await commandersResponse.json();
const commanders = Array.isArray(commandersData.stats)
? commandersData.stats
: [];
topCommanders = commanders.slice(0, 5);
// Initialize charts after data is loaded
setTimeout(() => initCharts(commandersData.charts), 100);
}
} catch (error) {
console.error("Failed to load dashboard data:", error);
} finally {
loading = false;
}
}
function initCharts(chartData) {
if (!browser || !Chart) return;
// Destroy existing charts if any
if (charts.colorWinRate) charts.colorWinRate.destroy();
if (charts.playerCount) charts.playerCount.destroy();
// Pastel color palette (15 colors)
const pastelColors = [
"#FFD93D",
"#D4A5FF",
"#FF9E9E",
"#B4E7FF",
"#FFA94D",
"#9D84B7",
"#FF85B3",
"#4D96FF",
"#FFCB69",
"#56AB91",
"#FF6B6B",
"#FFB3D9",
"#A8E6CF",
"#6BCB77",
"#C7CEEA",
];
// Color Identity Win Rate Chart
const colorLabels = chartData?.colors?.labels || [];
const colorData = chartData?.colors?.data || [];
const filteredIndices = colorData
.map((value, index) => (value > 0 ? index : -1))
.filter((index) => index !== -1);
const filteredLabels = filteredIndices.map((i) => colorLabels[i]);
const filteredData = filteredIndices.map((i) => colorData[i]);
const filteredColors = filteredIndices.map(
(_, index) => pastelColors[index % pastelColors.length],
);
const colorCanvas = document.getElementById("colorWinRateChart");
if (colorCanvas) {
const colorCtx = colorCanvas.getContext("2d");
charts.colorWinRate = new Chart(colorCtx, {
type: "doughnut",
data: {
labels: filteredLabels,
datasets: [
{
data: filteredData,
backgroundColor: filteredColors,
borderWidth: 1,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: "right" },
},
},
});
}
// Player Count Win Rate Chart
const playerLabels = chartData?.playerCounts?.labels || [];
const playerData = chartData?.playerCounts?.data || [];
const playerFilteredIndices = playerData
.map((value, index) => (value > 0 ? index : -1))
.filter((index) => index !== -1);
const playerFilteredLabels = playerFilteredIndices.map(
(i) => playerLabels[i],
);
const playerFilteredData = playerFilteredIndices.map((i) => playerData[i]);
const playerCanvas = document.getElementById("playerCountChart");
if (playerCanvas) {
const playerCtx = playerCanvas.getContext("2d");
charts.playerCount = new Chart(playerCtx, {
type: "bar",
data: {
labels: playerFilteredLabels,
datasets: [
{
label: "Win Rate (%)",
data: playerFilteredData,
backgroundColor: "#6366f1",
borderRadius: 4,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: 100,
},
},
},
});
}
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
</script>
<svelte:head>
<title>Dashboard - EDH Stats Tracker</title>
<meta
name="description"
content="Your EDH/Commander game statistics dashboard"
/>
</svelte:head>
<ProtectedRoute>
<div class="min-h-screen bg-gray-50 flex flex-col">
<NavBar />
<main class="container mx-auto px-4 py-8 flex-1">
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="loading-spinner w-12 h-12"></div>
</div>
{:else}
<!-- Stats Overview -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Total Games -->
<div class="card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-1">Total Games</p>
<p class="text-3xl font-bold text-gray-900">
{stats.totalGames}
</p>
</div>
</div>
</div>
<!-- Win Rate -->
<div class="card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-1">Win Rate</p>
<p class="text-3xl font-bold text-gray-900">{stats.winRate}%</p>
</div>
</div>
</div>
<!-- Commanders -->
<div class="card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-1">Commanders</p>
<div class="flex items-baseline gap-2">
<p class="text-3xl font-bold text-gray-900">
{stats.totalCommanders}
</p>
{#if stats.inactiveCommanders > 0}
<span class="text-sm text-gray-500">
({stats.inactiveCommanders} archived)
</span>
{/if}
</div>
</div>
</div>
</div>
<!-- Avg Rounds -->
<div class="card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 mb-1">Avg Rounds</p>
<p class="text-3xl font-bold text-gray-900">
{stats.avgRounds}
</p>
</div>
</div>
</div>
</div>
<!-- Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Color Win Rate Chart -->
<div class="card">
<h3 class="text-lg font-semibold mb-6">
Win Rate by Color Identity
</h3>
<div class="h-64 relative">
<canvas id="colorWinRateChart"></canvas>
</div>
</div>
<!-- Player Count Chart -->
<div class="card">
<h3 class="text-lg font-semibold mb-6">Win Rate by Player Count</h3>
<div class="h-64 relative">
<canvas id="playerCountChart"></canvas>
</div>
</div>
</div>
<!-- Recent Games and Top Commanders -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Recent Games -->
<div class="card">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-900">Recent Games</h2>
<a
href="/games"
class="text-sm text-indigo-600 hover:text-indigo-800"
>
View All →
</a>
</div>
{#if recentGames.length === 0}
<div class="text-center py-8 text-gray-500">
<p>No games logged yet</p>
<a
href="/games"
class="text-indigo-600 hover:text-indigo-800 mt-2 inline-block"
>
Log your first game
</a>
</div>
{:else}
<div class="space-y-3">
{#each recentGames as game}
<div
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div class="flex-1">
<p class="font-medium text-gray-900">
{game.commanderName}
</p>
<p class="text-sm text-gray-600">
{formatDate(game.date)}{game.rounds} rounds • {game.playerCount}
players
</p>
</div>
<div class="ml-4">
{#if game.won}
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
>
Won
</span>
{:else}
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
>
Loss
</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Top Commanders -->
<div class="card">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-900">Top Commanders</h2>
<a
href="/commanders"
class="text-sm text-indigo-600 hover:text-indigo-800"
>
View All →
</a>
</div>
{#if topCommanders.length === 0}
<div class="text-center py-8 text-gray-500">
<p>No commanders yet</p>
<a
href="/commanders"
class="text-indigo-600 hover:text-indigo-800 mt-2 inline-block"
>
Add your first commander
</a>
</div>
{:else}
<div class="space-y-3">
{#each topCommanders as commander}
<div
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div class="flex-1">
<p class="font-medium text-gray-900">{commander.name}</p>
<p class="text-sm text-gray-600">
{commander.totalGames} games • {commander.winRate}% win
rate
</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</main>
<Footer />
</div>
</ProtectedRoute>

View File

@@ -0,0 +1,732 @@
<script>
import { onMount, tick } from "svelte";
import { browser } from "$app/environment";
import { authenticatedFetch } from "$stores/auth";
import NavBar from "$components/NavBar.svelte";
import ProtectedRoute from "$components/ProtectedRoute.svelte";
import Footer from "$components/Footer.svelte";
let showLogForm = false;
let games = [];
let commanders = [];
let allCommanders = [];
let loading = false;
let loadingMore = false;
let limit = 20;
let offset = 0;
let hasMore = false;
let submitting = false;
let editingGame = null;
let serverError = "";
let logFormElement;
let deleteConfirm = {
show: false,
gameId: null,
deleting: false,
};
let newGame = {
date: new Date().toISOString().split("T")[0],
commanderId: "",
playerCount: 4,
won: false,
rounds: 8,
startingPlayerWon: false,
solRingTurnOneWon: false,
notes: "",
};
$: formData = editingGame || newGame;
onMount(async () => {
await Promise.all([loadCommanders(), loadGames()]);
loadPrefilled();
});
async function loadCommanders() {
try {
const response = await authenticatedFetch("/api/commanders");
if (response.ok) {
const data = await response.json();
allCommanders = data.commanders || [];
}
} catch (error) {
console.error("Failed to load commanders:", error);
}
}
$: commanders = deriveCommanderOptions(allCommanders, formData?.commanderId);
async function loadGames({ append = false } = {}) {
if (append) {
if (loadingMore || !hasMore) return;
loadingMore = true;
} else {
loading = true;
offset = 0;
hasMore = false;
}
try {
const queryOffset = append ? offset : 0;
const params = new URLSearchParams({
limit: limit.toString(),
offset: queryOffset.toString(),
});
const response = await authenticatedFetch(
`/api/games?${params.toString()}`,
);
if (response.ok) {
const data = await response.json();
const incomingGames = data.games || [];
if (append) {
const existingIds = new Set(games.map((game) => game.id));
const deduped = incomingGames.filter(
(game) => !existingIds.has(game.id),
);
games = [...games, ...deduped];
} else {
games = incomingGames;
}
hasMore = data.pagination?.hasMore ?? false;
offset = queryOffset + incomingGames.length;
} else {
console.error("Failed to load games:", await response.text());
}
} catch (error) {
console.error("Failed to load games:", error);
} finally {
if (append) {
loadingMore = false;
} else {
loading = false;
}
}
}
async function loadMore() {
await loadGames({ append: true });
}
async function loadPrefilled() {
if (!browser) return;
const prefilled = localStorage.getItem("edh-prefill-game");
if (prefilled) {
try {
const data = JSON.parse(prefilled);
newGame.date = data.date || new Date().toISOString().split("T")[0];
newGame.rounds = data.rounds || 8;
newGame.notes =
`Ended after ${data.rounds} rounds in ${data.duration}.\nAverage time/round: ${data.avgTimePerRound}` ||
"";
showLogForm = true;
localStorage.removeItem("edh-prefill-game");
await tick();
logFormElement?.scrollIntoView({ behavior: "smooth", block: "start" });
} catch (error) {
console.error("Error loading prefilled game data:", error);
}
}
}
async function handleLogGame(e) {
e.preventDefault();
serverError = "";
if (!formData.commanderId) {
serverError = "Please select a commander";
return;
}
if (editingGame) {
await handleUpdateGame();
} else {
await handleCreateGame();
}
}
async function handleCreateGame() {
submitting = true;
try {
const payload = {
commanderId: formData.commanderId,
date: formData.date,
playerCount: parseInt(formData.playerCount),
won: formData.won === true || formData.won === "true",
rounds: parseInt(formData.rounds),
startingPlayerWon:
formData.startingPlayerWon === true ||
formData.startingPlayerWon === "true",
solRingTurnOneWon:
formData.solRingTurnOneWon === true ||
formData.solRingTurnOneWon === "true",
};
if (formData.notes && formData.notes.trim()) {
payload.notes = formData.notes;
}
const response = await authenticatedFetch("/api/games", {
method: "POST",
body: JSON.stringify(payload),
});
if (response.ok) {
const data = await response.json();
games = [data.game, ...games];
offset += 1;
resetForm();
showLogForm = false;
// Reset the round counter state
if (browser) {
localStorage.removeItem("edh-round-counter-state");
}
} else {
const errorData = await response.json();
serverError = errorData.message || "Failed to log game";
}
} catch (error) {
console.error("Log game error:", error);
serverError = "Network error occurred";
} finally {
submitting = false;
}
}
async function handleUpdateGame() {
submitting = true;
try {
const payload = {
commanderId: formData.commanderId,
date: formData.date,
playerCount: parseInt(formData.playerCount),
won: formData.won === true || formData.won === "true",
rounds: parseInt(formData.rounds),
startingPlayerWon:
formData.startingPlayerWon === true ||
formData.startingPlayerWon === "true",
solRingTurnOneWon:
formData.solRingTurnOneWon === true ||
formData.solRingTurnOneWon === "true",
};
if (formData.notes && formData.notes.trim()) {
payload.notes = formData.notes;
}
const response = await authenticatedFetch(
`/api/games/${editingGame.id}`,
{
method: "PUT",
body: JSON.stringify(payload),
},
);
if (response.ok) {
const data = await response.json();
games = games.map((g) => (g.id === data.game.id ? data.game : g));
resetForm();
editingGame = null;
showLogForm = false;
} else {
const errorData = await response.json();
serverError = errorData.message || "Failed to update game";
}
} catch (error) {
console.error("Update game error:", error);
serverError = "Network error occurred";
} finally {
submitting = false;
}
}
async function startEdit(game) {
// Map API response to form fields
const formattedDate = game.date
? new Date(game.date).toISOString().split("T")[0]
: new Date().toISOString().split("T")[0];
// Ensure commanderId is a number to match select options
const cmdId = game.commanderId;
const finalCmdId = cmdId
? typeof cmdId === "number"
? cmdId
: parseInt(cmdId)
: "";
editingGame = {
id: game.id,
date: formattedDate,
commanderId: finalCmdId,
playerCount: game.playerCount || 4,
won: game.won || false,
rounds: game.rounds || 8,
startingPlayerWon: game.startingPlayerWon || false,
solRingTurnOneWon: game.solRingTurnOneWon || false,
notes: game.notes || "",
};
showLogForm = true;
serverError = "";
await tick();
logFormElement?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function cancelEdit() {
editingGame = null;
showLogForm = false;
serverError = "";
}
function resetForm() {
newGame = {
date: new Date().toISOString().split("T")[0],
commanderId: "",
playerCount: 4,
won: false,
rounds: 8,
startingPlayerWon: false,
solRingTurnOneWon: false,
notes: "",
};
}
function showDeleteConfirm(gameId) {
deleteConfirm = {
show: true,
gameId,
deleting: false,
};
}
async function confirmDelete() {
deleteConfirm.deleting = true;
try {
const response = await authenticatedFetch(
`/api/games/${deleteConfirm.gameId}`,
{
method: "DELETE",
},
);
if (response.ok) {
games = games.filter((g) => g.id !== deleteConfirm.gameId);
offset = Math.max(0, offset - 1);
deleteConfirm = { show: false, gameId: null, deleting: false };
} else {
serverError = "Failed to delete game";
deleteConfirm.deleting = false;
}
} catch (error) {
console.error("Delete game error:", error);
serverError = "Network error occurred";
deleteConfirm.deleting = false;
}
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function deriveCommanderOptions(allList, currentCommanderId) {
if (!Array.isArray(allList) || allList.length === 0) {
return [];
}
const active = allList.filter((cmd) => !cmd.archived);
const normalizedId = normalizeCommanderId(currentCommanderId);
if (normalizedId === null) {
return active;
}
const current = allList.find((cmd) => cmd.id === normalizedId);
if (
current &&
current.archived &&
!active.some((cmd) => cmd.id === current.id)
) {
return [...active, current];
}
return active;
}
function normalizeCommanderId(value) {
if (value === undefined || value === null || value === "") {
return null;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
</script>
<svelte:head>
<title>Game Log - EDH Stats Tracker</title>
<meta name="description" content="Log and manage your EDH/Commander games" />
</svelte:head>
<ProtectedRoute>
<div class="min-h-screen bg-gray-50 flex flex-col">
<NavBar />
<main class="container mx-auto px-4 py-8 flex-1">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Game Log</h1>
<button
on:click={() => {
showLogForm = !showLogForm;
if (!showLogForm) cancelEdit();
}}
class="btn btn-primary"
>
{#if showLogForm}
Cancel
{:else}
Log New Game
{/if}
</button>
</div>
<!-- Log Game Form -->
{#if showLogForm}
<div class="card mb-8">
<h2 class="text-xl font-bold mb-4">
{editingGame ? "Edit Game" : "Log New Game"}
</h2>
{#key editingGame?.id || "new"}
<form
on:submit={handleLogGame}
class="space-y-4"
bind:this={logFormElement}
>
<!-- Date and Commander Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
for="date"
class="block text-sm font-medium text-gray-700 mb-1"
>
Date
</label>
<input
id="date"
type="date"
bind:value={formData.date}
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label
for="commander"
class="block text-sm font-medium text-gray-700 mb-1"
>
Commander *
</label>
<select
id="commander"
bind:value={formData.commanderId}
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="">Select a commander</option>
{#each commanders as commander}
<option value={commander.id}>
{commander.name}{commander.archived
? " (Archived)"
: ""}
</option>
{/each}
</select>
</div>
</div>
<!-- Player Count and Rounds -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
for="playerCount"
class="block text-sm font-medium text-gray-700 mb-1"
>
Player Count
</label>
<input
id="playerCount"
type="number"
bind:value={formData.playerCount}
min="2"
max="8"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label
for="rounds"
class="block text-sm font-medium text-gray-700 mb-1"
>
Rounds
</label>
<input
id="rounds"
type="number"
bind:value={formData.rounds}
min="1"
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
<!-- Checkboxes -->
<div class="space-y-2">
<label class="flex items-center">
<input
type="checkbox"
bind:checked={formData.won}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<span class="ml-2 text-sm text-gray-900">I won this game</span
>
</label>
<label class="flex items-center">
<input
type="checkbox"
bind:checked={formData.startingPlayerWon}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<span class="ml-2 text-sm text-gray-900"
>Starting player won</span
>
</label>
<label class="flex items-center">
<input
type="checkbox"
bind:checked={formData.solRingTurnOneWon}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<span class="ml-2 text-sm text-gray-900">
Player with Sol Ring turn 1 won
</span>
</label>
</div>
<!-- Notes -->
<div>
<label
for="notes"
class="block text-sm font-medium text-gray-700 mb-1"
>
Notes (optional)
</label>
<textarea
id="notes"
bind:value={formData.notes}
rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Add any notes about this game..."
></textarea>
</div>
<!-- Error Message -->
{#if serverError}
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm font-medium text-red-800">{serverError}</p>
</div>
{/if}
<!-- Submit Button -->
<div class="flex gap-3">
<button
type="submit"
disabled={submitting}
class="flex-1 btn btn-primary disabled:opacity-50"
>
{#if submitting}
<div class="loading-spinner w-5 h-5 mx-auto"></div>
{:else if editingGame}
Update Game
{:else}
Log Game
{/if}
</button>
{#if editingGame}
<button
type="button"
on:click={cancelEdit}
class="btn btn-secondary"
>
Cancel
</button>
{/if}
</div>
</form>
{/key}
</div>
{/if}
<!-- Games List -->
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="loading-spinner w-12 h-12"></div>
</div>
{:else if games.length === 0}
<div class="card text-center py-12">
<p class="text-gray-600 mb-4">No games logged yet</p>
<button on:click={() => (showLogForm = true)} class="btn btn-primary">
Log Your First Game
</button>
</div>
{:else}
<div class="space-y-4">
{#each games as game}
<div
class="card hover:shadow-lg transition-shadow {game.won
? 'border-l-4 border-l-green-500'
: ''}"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-lg font-bold text-gray-900">
{game.commanderName}
</h3>
{#if game.won}
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
>
Won
</span>
{:else}
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
>
Loss
</span>
{/if}
</div>
<div class="text-sm text-gray-600 space-y-1">
<p>
{formatDate(game.date)}{game.rounds} rounds • {game.playerCount}
players
</p>
{#if game.startingPlayerWon}
<p>• Starting player won</p>
{/if}
{#if game.solRingTurnOneWon}
<p>• Sol Ring turn 1 won</p>
{/if}
{#if game.notes}
<p class="mt-2 text-gray-700">{game.notes}</p>
{/if}
</div>
</div>
<div class="flex gap-2 ml-4">
<button
on:click={() => startEdit(game)}
class="text-indigo-600 hover:text-indigo-800 text-xl font-medium"
>
Edit
</button>
<button
on:click={() => showDeleteConfirm(game.id)}
class="text-red-600 hover:text-red-800 text-xl font-medium"
>
Delete
</button>
</div>
</div>
</div>
{/each}
</div>
{#if hasMore}
<div class="flex justify-center pt-6">
<button
on:click={loadMore}
class="btn btn-secondary"
disabled={loadingMore}
>
{#if loadingMore}
<div class="loading-spinner w-5 h-5"></div>
{:else}
Load More
{/if}
</button>
</div>
{:else if hasMore && games.length > 0}
<p class="text-center text-gray-500 pt-6">
You have reached the end of the line.
</p>
{/if}
{/if}
</main>
<!-- Delete Confirmation Modal -->
{#if deleteConfirm.show}
<div
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50"
>
<button
class="absolute inset-0 w-full h-full cursor-default"
aria-label="Close dialog"
on:click={() =>
!deleteConfirm.deleting && (deleteConfirm.show = false)}
></button>
<div class="relative bg-white rounded-lg p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-bold text-gray-900 mb-2">Delete Game</h3>
<p class="text-gray-600 mb-6">
Are you sure you want to delete this game? This action cannot be
undone.
</p>
<div class="flex gap-3">
<button
on:click={confirmDelete}
disabled={deleteConfirm.deleting}
class="flex-1 btn btn-danger disabled:opacity-50"
>
{#if deleteConfirm.deleting}
<div class="loading-spinner w-5 h-5 mx-auto"></div>
{:else}
Delete
{/if}
</button>
<button
on:click={() => (deleteConfirm.show = false)}
disabled={deleteConfirm.deleting}
class="flex-1 btn btn-secondary"
>
Cancel
</button>
</div>
</div>
</div>
{/if}
<Footer />
</div>
</ProtectedRoute>

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,544 @@
<script>
import { auth, currentUser } from "$stores/auth";
import { authenticatedFetch } from "$stores/auth";
import NavBar from "$components/NavBar.svelte";
import ProtectedRoute from "$components/ProtectedRoute.svelte";
import Footer from "$components/Footer.svelte";
let usernameLoading = false;
let passwordLoading = false;
let deleteLoading = false;
let usernameError = "";
let usernameSuccess = "";
let passwordError = "";
let passwordSuccess = "";
let deleteError = "";
let showPasswordForm = false;
let showUsernameForm = false;
let showDeleteConfirm = false;
let deleteConfirmText = "";
let newUsername = "";
let passwordData = {
currentPassword: "",
newPassword: "",
confirmPassword: "",
};
let errors = {};
function validateUsername() {
errors = {};
if (!newUsername) {
errors.username = "Username is required";
return false;
}
if (newUsername.length < 3) {
errors.username = "Username must be at least 3 characters";
return false;
}
if (newUsername.length > 50) {
errors.username = "Username must be less than 50 characters";
return false;
}
if (!/^[a-zA-Z0-9_-]+$/.test(newUsername)) {
errors.username =
"Username can only contain letters, numbers, underscores, and hyphens";
return false;
}
if (newUsername === $currentUser?.username) {
errors.username = "New username must be different from current username";
return false;
}
return true;
}
async function handleUpdateUsername(e) {
e.preventDefault();
usernameError = "";
usernameSuccess = "";
if (!validateUsername()) return;
usernameLoading = true;
try {
const response = await authenticatedFetch("/api/auth/update-username", {
method: "PUT",
body: JSON.stringify({
newUsername: newUsername.toLowerCase().trim(),
}),
});
if (response.ok) {
const data = await response.json();
usernameSuccess = "Username updated successfully!";
// Update the auth store with new user data
auth.updateUser(data.user);
newUsername = "";
showUsernameForm = false;
} else {
const errorData = await response.json();
usernameError = errorData.message || "Failed to update username";
}
} catch (error) {
console.error("Update username error:", error);
usernameError = "Network error occurred";
} finally {
usernameLoading = false;
}
}
async function handleDeleteAccount() {
if (deleteConfirmText !== $currentUser?.username) return;
deleteLoading = true;
deleteError = "";
try {
const response = await authenticatedFetch("/api/auth/me", {
method: "DELETE",
});
if (response.ok) {
// Logout clears the store and redirects to /login
auth.logout();
} else {
const errorData = await response.json();
deleteError = errorData.message || "Failed to delete account";
}
} catch (error) {
console.error("Delete account error:", error);
deleteError = "Network error occurred";
} finally {
deleteLoading = false;
}
}
function validatePasswords() {
errors = {};
if (!passwordData.currentPassword) {
errors.currentPassword = "Current password is required";
}
if (!passwordData.newPassword) {
errors.newPassword = "New password is required";
} else if (passwordData.newPassword.length < 8) {
errors.newPassword = "Password must be at least 8 characters";
} else if (!/(?=.*[a-z])/.test(passwordData.newPassword)) {
errors.newPassword =
"Password must contain at least one lowercase letter";
} else if (!/(?=.*[A-Z])/.test(passwordData.newPassword)) {
errors.newPassword =
"Password must contain at least one uppercase letter";
} else if (!/(?=.*\d)/.test(passwordData.newPassword)) {
errors.newPassword = "Password must contain at least one number";
}
if (passwordData.newPassword !== passwordData.confirmPassword) {
errors.confirmPassword = "Passwords do not match";
}
return Object.keys(errors).length === 0;
}
async function handleChangePassword(e) {
e.preventDefault();
passwordError = "";
passwordSuccess = "";
if (!validatePasswords()) return;
passwordLoading = true;
try {
const response = await authenticatedFetch("/api/auth/change-password", {
method: "POST",
body: JSON.stringify({
currentPassword: passwordData.currentPassword,
newPassword: passwordData.newPassword,
}),
});
if (response.ok) {
passwordSuccess = "Password changed successfully!";
passwordData = {
currentPassword: "",
newPassword: "",
confirmPassword: "",
};
showPasswordForm = false;
} else {
const errorData = await response.json();
passwordError = errorData.message || "Failed to change password";
}
} catch (error) {
console.error("Change password error:", error);
passwordError = "Network error occurred";
} finally {
passwordLoading = false;
}
}
</script>
<svelte:head>
<title>Profile - EDH Stats Tracker</title>
<meta name="description" content="Manage your profile and settings" />
</svelte:head>
<ProtectedRoute>
<div class="min-h-screen bg-gray-50 flex flex-col">
<NavBar />
<main class="container mx-auto px-4 py-8 max-w-2xl flex-1">
<h1 class="text-3xl font-bold text-gray-900 mb-6">Profile Settings</h1>
<!-- Global Success Messages -->
{#if usernameSuccess && !showUsernameForm}
<div class="rounded-md bg-green-50 p-4 mb-6">
<p class="text-sm font-medium text-green-800">{usernameSuccess}</p>
</div>
{/if}
{#if passwordSuccess && !showPasswordForm}
<div class="rounded-md bg-green-50 p-4 mb-6">
<p class="text-sm font-medium text-green-800">{passwordSuccess}</p>
</div>
{/if}
<!-- User Info -->
<div class="card mb-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Account Information</h2>
{#if !showUsernameForm}
<button
on:click={() => {
showUsernameForm = true;
newUsername = $currentUser?.username || "";
usernameError = "";
usernameSuccess = "";
errors = {};
}}
class="btn btn-primary btn-sm"
>
Edit Username
</button>
{/if}
</div>
{#if showUsernameForm}
<form on:submit={handleUpdateUsername} class="space-y-4 mb-4">
<div>
<label
for="newUsername"
class="block text-sm font-medium text-gray-700 mb-1"
>
New Username
</label>
<input
id="newUsername"
type="text"
bind:value={newUsername}
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.username
? 'border-red-500'
: ''}"
placeholder="Enter new username"
/>
{#if errors.username}
<p class="mt-1 text-sm text-red-600">{errors.username}</p>
{/if}
<p class="mt-1 text-xs text-gray-500">
3-50 characters, letters, numbers, underscores, and hyphens only
</p>
</div>
{#if usernameSuccess}
<div class="rounded-md bg-green-50 p-4">
<p class="text-sm font-medium text-green-800">
{usernameSuccess}
</p>
</div>
{/if}
{#if usernameError}
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm font-medium text-red-800">{usernameError}</p>
</div>
{/if}
<div class="flex gap-3">
<button
type="submit"
disabled={usernameLoading}
class="flex-1 btn btn-primary disabled:opacity-50"
>
{#if usernameLoading}
<div class="loading-spinner w-5 h-5 mx-auto"></div>
{:else}
Update Username
{/if}
</button>
<button
type="button"
on:click={() => {
showUsernameForm = false;
newUsername = "";
errors = {};
usernameError = "";
}}
class="btn btn-secondary"
>
Cancel
</button>
</div>
</form>
{/if}
<div class="space-y-3">
<div>
<p class="text-sm text-gray-600">Current Username</p>
<p class="text-lg font-medium text-gray-900">
{$currentUser?.username || "User"}
</p>
</div>
{#if $currentUser?.email}
<div>
<p class="text-sm text-gray-600">Email</p>
<p class="text-lg font-medium text-gray-900">
{$currentUser.email}
</p>
</div>
{/if}
</div>
</div>
<!-- Change Password -->
<div class="card">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold">Change Password</h2>
{#if !showPasswordForm}
<button
on:click={() => {
showPasswordForm = true;
passwordError = "";
passwordSuccess = "";
errors = {};
}}
class="btn btn-primary btn-sm"
>
Change Password
</button>
{/if}
</div>
{#if showPasswordForm}
<form on:submit={handleChangePassword} class="space-y-4">
<div>
<label
for="currentPassword"
class="block text-sm font-medium text-gray-700 mb-1"
>
Current Password
</label>
<input
id="currentPassword"
type="password"
bind:value={passwordData.currentPassword}
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.currentPassword
? 'border-red-500'
: ''}"
/>
{#if errors.currentPassword}
<p class="mt-1 text-sm text-red-600">
{errors.currentPassword}
</p>
{/if}
</div>
<div>
<label
for="newPassword"
class="block text-sm font-medium text-gray-700 mb-1"
>
New Password
</label>
<input
id="newPassword"
type="password"
bind:value={passwordData.newPassword}
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.newPassword
? 'border-red-500'
: ''}"
/>
{#if errors.newPassword}
<p class="mt-1 text-sm text-red-600">{errors.newPassword}</p>
{:else}
<p class="mt-1 text-xs text-gray-500">
At least 8 characters with uppercase, lowercase, and a number
</p>
{/if}
</div>
<div>
<label
for="confirmPassword"
class="block text-sm font-medium text-gray-700 mb-1"
>
Confirm New Password
</label>
<input
id="confirmPassword"
type="password"
bind:value={passwordData.confirmPassword}
required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.confirmPassword
? 'border-red-500'
: ''}"
/>
{#if errors.confirmPassword}
<p class="mt-1 text-sm text-red-600">
{errors.confirmPassword}
</p>
{/if}
</div>
{#if passwordSuccess}
<div class="rounded-md bg-green-50 p-4">
<p class="text-sm font-medium text-green-800">
{passwordSuccess}
</p>
</div>
{/if}
{#if passwordError}
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm font-medium text-red-800">{passwordError}</p>
</div>
{/if}
<div class="flex gap-3">
<button
type="submit"
disabled={passwordLoading}
class="flex-1 btn btn-primary disabled:opacity-50"
>
{#if passwordLoading}
<div class="loading-spinner w-5 h-5 mx-auto"></div>
{:else}
Update Password
{/if}
</button>
<button
type="button"
on:click={() => {
showPasswordForm = false;
passwordData = {
currentPassword: "",
newPassword: "",
confirmPassword: "",
};
errors = {};
}}
class="btn btn-secondary"
>
Cancel
</button>
</div>
</form>
{/if}
</div>
<!-- Danger Zone -->
<div class="card mt-6 border border-red-200">
<h2 class="text-xl font-bold text-red-600 mb-4">Danger Zone</h2>
<p class="text-sm text-gray-600 mb-4">
Permanently delete your account and all associated data including
games, commanders, and statistics. This action cannot be undone.
</p>
<button
class="btn btn-danger"
on:click={() => {
showDeleteConfirm = true;
deleteError = "";
deleteConfirmText = "";
}}
>
Delete Account
</button>
</div>
<!-- Delete Confirmation Modal -->
{#if showDeleteConfirm}
<div
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50"
>
<button
class="absolute inset-0 w-full h-full cursor-default"
aria-label="Close dialog"
on:click={() => !deleteLoading && (showDeleteConfirm = false)}
></button>
<div class="relative bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 class="text-lg font-bold text-gray-900 mb-2">Delete Account</h3>
<p class="text-gray-600 mb-4">
This will permanently delete your account and all your data. This
action <strong>cannot be undone</strong>.
</p>
<p class="text-sm text-gray-700 mb-2">
Type your username <strong>{$currentUser?.username}</strong> to confirm:
</p>
<input
type="text"
bind:value={deleteConfirmText}
placeholder="Enter your username"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-red-500 focus:border-red-500 mb-4"
/>
{#if deleteError}
<div class="rounded-md bg-red-50 p-3 mb-4">
<p class="text-sm font-medium text-red-800">{deleteError}</p>
</div>
{/if}
<div class="flex gap-3">
<button
on:click={handleDeleteAccount}
disabled={deleteLoading ||
deleteConfirmText !== $currentUser?.username}
class="flex-1 btn btn-danger disabled:opacity-50"
>
{#if deleteLoading}
<div class="loading-spinner w-5 h-5 mx-auto"></div>
{:else}
Delete My Account
{/if}
</button>
<button
on:click={() => {
showDeleteConfirm = false;
deleteConfirmText = "";
deleteError = "";
}}
disabled={deleteLoading}
class="flex-1 btn btn-secondary"
>
Cancel
</button>
</div>
</div>
</div>
{/if}
</main>
<Footer />
</div>
</ProtectedRoute>

View File

@@ -0,0 +1,615 @@
<script>
import { auth } from "$stores/auth";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
let formData = {
username: "",
email: "",
password: "",
confirmPassword: "",
terms: false,
};
let errors = {};
let showPassword = false;
let showConfirmPassword = false;
let loading = false;
let serverError = "";
let successMessage = "";
let allowRegistration = true;
let showTermsModal = false;
onMount(() => {
(async () => {
await auth.checkRegistrationConfig();
})();
const unsubscribe = auth.subscribe(($auth) => {
allowRegistration = $auth.allowRegistration;
});
return () => {
unsubscribe();
};
});
function validateUsername() {
if (!formData.username.trim()) {
errors.username = "Username is required";
} else if (formData.username.length < 3) {
errors.username = "Username must be at least 3 characters";
} else if (formData.username.length > 50) {
errors.username = "Username must be less than 50 characters";
} else if (!/^[a-zA-Z0-9_-]+$/.test(formData.username)) {
errors.username =
"Username can only contain letters, numbers, underscores, and hyphens";
} else {
errors.username = "";
}
}
function validateEmail() {
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = "Please enter a valid email address";
} else {
errors.email = "";
}
}
function validatePassword() {
if (!formData.password) {
errors.password = "Password is required";
} else if (formData.password.length < 8) {
errors.password = "Password must be at least 8 characters";
} else if (formData.password.length > 100) {
errors.password = "Password must be less than 100 characters";
} else if (!/(?=.*[a-z])/.test(formData.password)) {
errors.password = "Password must contain at least one lowercase letter";
} else if (!/(?=.*[A-Z])/.test(formData.password)) {
errors.password = "Password must contain at least one uppercase letter";
} else if (!/(?=.*\d)/.test(formData.password)) {
errors.password = "Password must contain at least one number";
} else {
errors.password = "";
}
}
function validateConfirmPassword() {
if (!formData.confirmPassword) {
errors.confirmPassword = "Please confirm your password";
} else if (formData.password !== formData.confirmPassword) {
errors.confirmPassword = "Passwords do not match";
} else {
errors.confirmPassword = "";
}
}
function validateTerms() {
if (!formData.terms) {
errors.terms = "You must agree to the Terms of Service";
} else {
errors.terms = "";
}
}
async function handleRegister(e) {
e.preventDefault();
// Validate all fields
validateUsername();
validateEmail();
validatePassword();
validateConfirmPassword();
validateTerms();
if (Object.values(errors).some((error) => error)) {
return;
}
loading = true;
serverError = "";
const result = await auth.register(
formData.username,
formData.email,
formData.password,
);
if (result.success) {
successMessage = "Account created successfully! Redirecting...";
setTimeout(() => {
goto("/dashboard");
}, 1000);
} else {
serverError = result.error;
}
loading = false;
}
</script>
<svelte:head>
<title>Register - EDH Stats Tracker</title>
<meta
name="description"
content="Create an account to track your Magic: The Gathering EDH/Commander games"
/>
</svelte:head>
<div class="min-h-full flex flex-col py-12 px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-center flex-1">
<div class="max-w-md w-full space-y-8">
<!-- Header -->
<div class="text-center">
<h1 class="text-4xl font-bold font-mtg text-edh-primary mb-2">
EDH Stats
</h1>
<h2 class="text-xl text-gray-600">Create your account</h2>
</div>
{#if !allowRegistration}
<div class="card">
<div class="text-center py-8">
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
></path>
</svg>
<h3 class="mt-2 text-lg font-medium text-gray-900">
Registration Closed
</h3>
<p class="mt-1 text-sm text-gray-500">
New user registration is currently disabled.
</p>
<div class="mt-6">
<a href="/login" class="btn btn-primary"> Go to Login </a>
</div>
</div>
</div>
{:else}
<!-- Registration Form -->
<div class="card">
<form class="space-y-6" on:submit={handleRegister}>
<!-- Username -->
<div>
<label
for="username"
class="block text-sm font-medium text-gray-700 mb-1"
>
Username *
</label>
<input
id="username"
type="text"
required
bind:value={formData.username}
on:input={validateUsername}
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.username
? 'border-red-500'
: ''}"
placeholder="Choose a username"
/>
{#if errors.username}
<p class="mt-1 text-sm text-red-600">{errors.username}</p>
{/if}
</div>
<!-- Email (Optional) -->
<div>
<label
for="email"
class="block text-sm font-medium text-gray-700 mb-1"
>
Email (optional)
</label>
<input
id="email"
type="email"
bind:value={formData.email}
on:input={validateEmail}
class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.email
? 'border-red-500'
: ''}"
placeholder="your.email@example.com"
/>
{#if errors.email}
<p class="mt-1 text-sm text-red-600">{errors.email}</p>
{/if}
</div>
<!-- Password -->
<div>
<label
for="password"
class="block text-sm font-medium text-gray-700 mb-1"
>
Password *
</label>
<div class="relative">
<input
id="password"
type={showPassword ? "text" : "password"}
required
bind:value={formData.password}
on:input={validatePassword}
class="appearance-none block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.password
? 'border-red-500'
: ''}"
placeholder="Create a strong password"
/>
<button
type="button"
on:click={() => (showPassword = !showPassword)}
class="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg
class="h-5 w-5 text-gray-400 hover:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if showPassword}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
></path>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
{/if}
</svg>
</button>
</div>
{#if errors.password}
<p class="mt-1 text-sm text-red-600">{errors.password}</p>
{/if}
</div>
<!-- Confirm Password -->
<div>
<label
for="confirmPassword"
class="block text-sm font-medium text-gray-700 mb-1"
>
Confirm Password *
</label>
<div class="relative">
<input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
required
bind:value={formData.confirmPassword}
on:input={validateConfirmPassword}
class="appearance-none block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 {errors.confirmPassword
? 'border-red-500'
: ''}"
placeholder="Confirm your password"
/>
<button
type="button"
on:click={() => (showConfirmPassword = !showConfirmPassword)}
class="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<svg
class="h-5 w-5 text-gray-400 hover:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if showConfirmPassword}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
></path>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
></path>
{/if}
</svg>
</button>
</div>
{#if errors.confirmPassword}
<p class="mt-1 text-sm text-red-600">
{errors.confirmPassword}
</p>
{/if}
</div>
<!-- Terms -->
<div>
<div class="flex items-start">
<input
id="terms"
type="checkbox"
bind:checked={formData.terms}
on:change={validateTerms}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded mt-1"
/>
<label for="terms" class="ml-2 block text-sm text-gray-900">
I agree to the
<button
type="button"
class="text-indigo-600 hover:text-indigo-500 underline-offset-4 underline"
on:click={() => (showTermsModal = true)}
>
Terms of Service
</button>
</label>
</div>
{#if errors.terms}
<p class="mt-1 text-sm text-red-600">{errors.terms}</p>
{/if}
</div>
<!-- Success Message -->
{#if successMessage}
<div class="rounded-md bg-green-50 p-4">
<p class="text-sm font-medium text-green-800">
{successMessage}
</p>
</div>
{/if}
<!-- Server Error -->
{#if serverError}
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm font-medium text-red-800">{serverError}</p>
</div>
{/if}
<!-- Submit -->
<button
type="submit"
disabled={loading}
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{#if loading}
<div class="loading-spinner w-5 h-5"></div>
{:else}
Create Account
{/if}
</button>
</form>
<!-- Links -->
<div class="mt-6 text-center space-y-2">
<p class="text-sm text-gray-600">
Already have an account?
<a
href="/login"
class="font-medium text-indigo-600 hover:text-indigo-500"
>
Sign in
</a>
</p>
<p class="text-sm text-gray-600">
<a
href="/"
class="font-medium text-indigo-600 hover:text-indigo-500"
>
← Back to Home
</a>
</p>
</div>
</div>
{/if}
</div>
</div>
</div>
{#if showTermsModal}
<div class="fixed inset-0 z-40 flex items-center justify-center">
<div
class="absolute inset-0 bg-gray-900 bg-opacity-50"
on:click={() => (showTermsModal = false)}
></div>
<div
class="relative z-50 w-full max-w-3xl mx-4 bg-white rounded-2xl shadow-2xl flex flex-col max-h-[90vh]"
>
<div
class="flex items-center justify-between border-b border-gray-200 px-6 py-4"
>
<div>
<p class="text-sm uppercase tracking-wide text-gray-500">
Terms of Service
</p>
<h3 class="text-xl font-semibold text-gray-900">EDH Stats Tracker</h3>
</div>
<button
class="text-gray-400 hover:text-gray-600"
on:click={() => (showTermsModal = false)}
aria-label="Close terms of service"
>
<svg
class="w-6 h-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-6 text-gray-700">
<p class="text-gray-600 mb-6 text-sm">Last updated: January 2026</p>
<div class="prose prose-sm max-w-none">
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
Welcome to EDH Stats Tracker
</h2>
<p class="text-gray-700 mb-4">
By creating an account and using EDH Stats Tracker, you agree to
these Terms of Service. We've kept them simple and
straightforward—no legal jargon that makes your brain hurt. (You're
welcome.)
</p>
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
1. What This Service Is
</h2>
<p class="text-gray-700 mb-4">
EDH Stats Tracker is a web application designed to help Magic: The
Gathering players track, analyze, and celebrate their EDH/Commander
game statistics. We store your game records, commanders, and
associated statistics. Think of us as your personal game journal
that actually does math for you.
</p>
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
2. User Accounts
</h2>
<p class="text-gray-700 mb-4">
You are responsible for maintaining the confidentiality of your
password. You agree not to share your account credentials with
anyone else. If someone logs into your account and logs all your
games as losses, we'll sympathize, but that's on you.
</p>
<p class="text-gray-700 mb-4">
You represent that the information you provide during registration
is accurate and true. If you use a fake name, that's between you and
Magic's lore team.
</p>
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
3. Your Content
</h2>
<p class="text-gray-700 mb-4">
All game records, commander lists, notes, and data you enter into
EDH Stats Tracker remain your property. We don't own your stats—we
just help you organize them. We won't sell your data, trade it for
pack equity, or share it with strangers. (We're not monsters.)
</p>
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
4. Acceptable Use
</h2>
<p class="text-gray-700 mb-4">
You agree to use EDH Stats Tracker for its intended purpose:
tracking and analyzing your EDH games. Don't use it to harass,
deceive, or cause harm to others. Be cool.
</p>
<p class="text-gray-700 mb-4">
Don't try to break the service through hacking, automated attacks,
or other malicious means. If you find a security vulnerability,
please let us know responsibly instead.
</p>
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
5. Service Availability
</h2>
<p class="text-gray-700 mb-4">
We aim to keep EDH Stats Tracker available and reliable. However,
like all software, it may occasionally go down for maintenance or
experience technical issues. We're doing our best here.
</p>
<p class="text-gray-700 mb-4">
We reserve the right to make changes to the service, add features,
or modify functionality as we see fit. We'll try to keep breaking
changes to a minimum.
</p>
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
6. Limitation of Liability
</h2>
<p class="text-gray-700 mb-4">
EDH Stats Tracker is provided "as is." While we work hard to make it
great, we don't guarantee it will be perfect or meet every need.
We're not liable for data loss, lost wins, or your opponent's lucky
top-decks.
</p>
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
7. Changes to Terms
</h2>
<p class="text-gray-700 mb-4">
We may update these Terms of Service from time to time. We'll let
you know about significant changes. Your continued use of the
service after changes means you accept the new terms.
</p>
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
8. Account Termination
</h2>
<p class="text-gray-700 mb-4">
You can delete your account at any time. Your data will be removed
from our systems in accordance with our privacy practices. If you
violate these terms, we may disable your account.
</p>
<h2 class="text-2xl font-bold text-gray-900 mt-6 mb-4">
9. Questions?
</h2>
<p class="text-gray-700 mb-4">
If you have questions about these terms, please reach out. We're
reasonable people (at least we think so).
</p>
<div class="mt-8 p-6 bg-blue-50 border border-blue-200 rounded-lg">
<p class="text-blue-900 text-sm">
<strong>TL;DR:</strong> Use the service as intended, keep your password
safe, it's your responsibility. We'll keep your data private and try
to keep the service running. Don't be a jerk. That's it.
</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-6 py-4 flex justify-end gap-3">
<button
type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900"
on:click={() => (showTermsModal = false)}
>
Close
</button>
<button
type="button"
class="btn btn-primary"
on:click={() => {
showTermsModal = false;
formData.terms = true;
errors.terms = "";
}}
>
I Agree
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,302 @@
<script>
import { onMount, onDestroy } from "svelte";
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import NavBar from "$components/NavBar.svelte";
import ProtectedRoute from "$components/ProtectedRoute.svelte";
import Footer from "$components/Footer.svelte";
let counterActive = false;
let currentRound = 1;
let startTime = null;
let elapsedTime = "00:00:00";
let avgTimePerRound = "00:00";
let timerInterval = null;
let hasPausedGame = false;
let pausedElapsedTime = 0;
onMount(() => {
loadCounter();
if (counterActive) {
startTimer();
}
});
onDestroy(() => {
clearTimer();
});
function startCounter() {
counterActive = true;
hasPausedGame = false;
if (!startTime) {
startTime = new Date();
} else {
// Resuming: adjust start time for paused duration
startTime = new Date(Date.now() - pausedElapsedTime * 1000);
}
saveCounter();
startTimer();
}
function stopCounter() {
counterActive = false;
hasPausedGame = true;
if (startTime) {
pausedElapsedTime = Math.floor((Date.now() - new Date(startTime)) / 1000);
}
clearTimer();
saveCounter();
}
function nextRound() {
currentRound++;
saveCounter();
}
function resetCounter() {
counterActive = false;
hasPausedGame = false;
currentRound = 1;
startTime = null;
elapsedTime = "00:00:00";
avgTimePerRound = "00:00";
pausedElapsedTime = 0;
clearTimer();
saveCounter();
}
function startTimer() {
clearTimer();
timerInterval = setInterval(updateTimer, 1000);
updateTimer();
}
function clearTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
function updateTimer() {
if (!startTime) return;
const now = Date.now();
const elapsed = Math.floor((now - new Date(startTime)) / 1000);
const hours = Math.floor(elapsed / 3600);
const minutes = Math.floor((elapsed % 3600) / 60);
const seconds = elapsed % 60;
elapsedTime = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
// Calculate average time per round
if (currentRound > 0) {
const avgSeconds = Math.floor(elapsed / currentRound);
const avgMins = Math.floor(avgSeconds / 60);
const avgSecs = avgSeconds % 60;
avgTimePerRound = `${String(avgMins).padStart(2, "0")}:${String(avgSecs).padStart(2, "0")}`;
}
}
function saveCounter() {
if (!browser) return;
localStorage.setItem(
"edh-round-counter-state",
JSON.stringify({
counterActive,
currentRound,
startTime,
elapsedTime,
avgTimePerRound,
hasPausedGame,
pausedElapsedTime,
}),
);
}
function loadCounter() {
if (!browser) return;
const saved = localStorage.getItem("edh-round-counter-state");
if (saved) {
try {
const data = JSON.parse(saved);
counterActive = data.counterActive || false;
currentRound = data.currentRound || 1;
startTime = data.startTime ? new Date(data.startTime) : null;
elapsedTime = data.elapsedTime || "00:00:00";
avgTimePerRound = data.avgTimePerRound || "00:00";
hasPausedGame = data.hasPausedGame || false;
pausedElapsedTime = data.pausedElapsedTime || 0;
} catch (error) {
console.error("Error loading counter:", error);
resetCounter();
}
}
}
function saveAndGoToGameLog() {
if (!browser) return;
const now = new Date();
localStorage.setItem(
"edh-prefill-game",
JSON.stringify({
date: now.toISOString().split("T")[0],
rounds: currentRound,
duration: elapsedTime,
startTime: startTime ? new Date(startTime).toISOString() : null,
endTime: now.toISOString(),
avgTimePerRound,
}),
);
goto("/games");
}
</script>
<svelte:head>
<title>Round Counter - EDH Stats Tracker</title>
<meta name="description" content="Track your game rounds and duration" />
</svelte:head>
<ProtectedRoute>
<div class="min-h-screen bg-gray-50 flex flex-col">
<NavBar />
<main class="container mx-auto px-4 py-8 flex-1">
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold text-gray-900 mb-8 text-center">
Round Counter
</h1>
<div class="card">
<!-- Round Display -->
<div class="text-center mb-8">
<p class="text-2xl text-gray-600 mb-2">Current Round</p>
<p class="text-8xl font-bold text-indigo-600 mb-6">
{currentRound}
</p>
<button
on:click={nextRound}
disabled={!counterActive && !hasPausedGame}
class="btn btn-primary text-lg px-8 py-3 disabled:opacity-50"
>
Next Round →
</button>
</div>
<!-- Timer Display -->
<div class="text-center mb-8 border-t pt-8">
<p class="text-xl text-gray-600 mb-2">Elapsed Time</p>
<p class="text-5xl font-bold text-gray-900 font-mono mb-4">
{elapsedTime}
</p>
<p class="text-lg text-gray-600">
Average per round: <span class="font-semibold"
>{avgTimePerRound}</span
>
</p>
</div>
<!-- Controls -->
<div class="flex gap-4 mb-6">
{#if !counterActive && !hasPausedGame}
<button
on:click={startCounter}
class="flex-1 btn btn-primary text-lg py-3 icon-button"
>
<svg class="btn-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5v14l11-7-11-7z" fill="currentColor" />
</svg>
<span>Start</span>
</button>
{:else if counterActive}
<button
on:click={stopCounter}
class="flex-1 btn btn-secondary text-lg py-3 icon-button"
>
<svg class="btn-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 5h4v14H6V5zm8 0h4v14h-4V5z" fill="currentColor" />
</svg>
<span>Pause</span>
</button>
{:else if hasPausedGame}
<button
on:click={startCounter}
class="flex-1 btn btn-primary text-lg py-3 icon-button"
>
<svg class="btn-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5v14l11-7-11-7z" fill="currentColor" />
</svg>
<span>Resume</span>
</button>
{/if}
<button
on:click={resetCounter}
class="flex-1 btn btn-secondary text-lg py-3 icon-button"
disabled={!counterActive && !hasPausedGame}
class:opacity-50={!counterActive && !hasPausedGame}
>
<svg
class="btn-icon btn-icon-reset"
viewBox="0 0 512 512"
aria-hidden="true"
>
<path
d="M480.1 192l7.9 0c13.3 0 24-10.7 24-24l0-144c0-9.7-5.8-18.5-14.8-22.2S477.9 .2 471 7L419.3 58.8C375 22.1 318 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1C79.2 135.5 159.3 64 256 64 300.4 64 341.2 79 373.7 104.3L327 151c-6.9 6.9-8.9 17.2-5.2 26.2S334.3 192 344 192l136.1 0zm29.4 100.5c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-44.4 0-85.2-15-117.7-40.3L185 361c6.9-6.9 8.9-17.2 5.2-26.2S177.7 320 168 320L24 320c-13.3 0-24 10.7-24 24L0 488c0 9.7 5.8 18.5 14.8 22.2S34.1 511.8 41 505l51.8-51.8C137 489.9 194 512 256 512 385 512 491.7 416.6 509.4 292.5z"
fill="currentColor"
/>
</svg>
<span>Reset</span>
</button>
</div>
<!-- Save Game Button -->
{#if counterActive || hasPausedGame}
<div class="text-center border-t pt-6">
<button
on:click={saveAndGoToGameLog}
class="btn btn-primary text-white px-8 py-4 text-lg font-bold w-full"
>
End Game & Log Results
</button>
<p class="text-sm text-gray-500 mt-2">
This will take you to the game log with pre-filled data
</p>
</div>
{/if}
</div>
</div>
</main>
<Footer />
</div>
</ProtectedRoute>
<style>
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-icon {
width: 1.5rem;
height: 1.5rem;
}
.btn-icon-reset {
width: 1.15rem;
height: 1.15rem;
}
</style>

View File

@@ -1,6 +1,6 @@
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;700&family=Inter:wght@300;400;500;600;700&display=swap');
/* Alpine.js x-cloak - hide elements until Alpine initializes */
/* Utility to hide elements until JS initializes */
[x-cloak] {
display: none !important;
}

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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -0,0 +1 @@
2.4.1

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 = {
darkMode: false,
content: ['./public/**/*.html', './public/**/*.js', './js/**/*.js'],
export default {
darkMode: 'media',
content: [
'./src/**/*.{html,js,svelte,ts}',
'./public/**/*.html',
'./public/**/*.js',
'./js/**/*.js'
],
theme: {
extend: {
colors: {

22
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,22 @@
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'
const dockerBackendHost =
process.env.VITE_DOCKER_BACKEND_HOST || 'edh-stats-backend'
const proxyTarget =
process.env.VITE_PROXY_TARGET ||
(process.env.DOCKER ? `http://${dockerBackendHost}:3000` : 'http://localhost:3002')
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 5173,
proxy: {
'/api': {
target: proxyTarget,
changeOrigin: true
}
}
}
})