Add EDH Stats backend and frontend scaffolding

This commit is contained in:
2026-01-14 20:12:05 +01:00
commit 993771f34c
32 changed files with 10440 additions and 0 deletions

29
.env.example Normal file
View File

@@ -0,0 +1,29 @@
# Environment Variables Template
# Copy this file to .env and update values
# Application Configuration
NODE_ENV=development
PORT=3000
HOST=0.0.0.0
# Security
JWT_SECRET=your-super-secure-jwt-secret-key-change-this-in-production
SESSION_SECRET=your-session-secret-change-this-in-production
# Database
DATABASE_PATH=/app/database/data/edh-stats.db
DATABASE_BACKUP_PATH=/app/database/data/backups
# CORS Configuration
CORS_ORIGIN=http://localhost:80
# Logging
LOG_LEVEL=info
# Rate Limiting
RATE_LIMIT_WINDOW=15
RATE_LIMIT_MAX=100
# Monitoring
HEALTH_CHECK_ENABLED=true
METRICS_ENABLED=false

102
.gitignore vendored Normal file
View File

@@ -0,0 +1,102 @@
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Database files
*.db
*.sqlite
*.sqlite3
database/data/*
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Docker
.dockerignore
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# Build outputs
dist/
build/
# Temporary files
tmp/
temp/

185
README.md Normal file
View File

@@ -0,0 +1,185 @@
# 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), SQLite, and Alpine.js for optimal performance and simplicity.
## Features
- **Secure Authentication**: JWT-based login system with password hashing
- **Commander Management**: Track all your commanders with MTG color identity
- **Game Logging**: Detailed game statistics including:
- Player count (2-8 players)
- Commander played
- Game date and duration
- Win/loss tracking
- Round count
- Starting player advantage
- Sol Ring turn one impact
- **Statistics Dashboard**: Visual analytics with Chart.js
- **Live Round Counter**: Track ongoing games in real-time
- **Responsive Design**: Mobile-friendly interface with Tailwind CSS
## Technology Stack
- **Backend**: Fastify with JWT authentication (HS512)
- **Database**: SQLite with volume persistence
- **Frontend**: Alpine.js (~10KB) with Tailwind CSS
- **Deployment**: Docker Compose with multi-stage builds
- **Charts**: Chart.js with Alpine.js reactivity
## Quick Start
```bash
# Clone the repository
git clone <repository-url>
cd edh-stats
# Start the application
docker-compose up -d
# Access the application
# Frontend: http://localhost:80
# Backend API: http://localhost:3000
```
## Project Structure
```
edh-stats/
├── README.md # This documentation
├── docker-compose.yml # Development environment
├── docker-compose.prod.yml # Production environment
├── .env.example # Environment variables template
├── .gitignore # Git ignore patterns
├── backend/
│ ├── Dockerfile # Multi-stage Docker build
│ ├── package.json # Node.js dependencies
│ ├── .dockerignore # Docker build optimizations
│ └── src/
│ ├── server.js # Main application entry point
│ ├── config/
│ ├── models/
│ ├── routes/
│ ├── middleware/
│ ├── database/
│ └── utils/
├── frontend/
│ ├── public/ # HTML pages
│ ├── css/ # Compiled styles
│ └── js/ # Alpine.js components
└── database/
└── data/ # SQLite data directory
```
## API Endpoints
### Authentication (`/api/auth`)
- `POST /register` - User registration
- `POST /login` - JWT token generation
- `POST /refresh` - Token refresh
### Commanders (`/api/commanders`)
- `GET /` - List user's commanders
- `POST /` - Create new commander
- `PUT /:id` - Update commander
- `DELETE /:id` - Delete commander
### Games (`/api/games`)
- `GET /` - List games with filtering
- `POST /` - Log new game
- `PUT /:id` - Update game
- `DELETE /:id` - Delete game
### Statistics (`/api/stats`)
- `GET /overview` - Overall statistics
- `GET /commanders/:id` - Commander performance
- `GET /trends` - Performance trends
- `GET /comparison` - Commander comparison
## Database Schema
The application uses SQLite with the following main tables:
- `users` - User accounts and authentication
- `commanders` - Commander cards with color identity
- `games` - Game records with detailed statistics
## Development
### Prerequisites
- Docker and Docker Compose
- Node.js 20+ (for local development)
- Git
### Development Setup
```bash
# Install dependencies (backend)
cd backend
npm install
# Install dependencies (frontend)
cd ../frontend
npm install
# Start development environment
docker-compose up
# Or run locally
cd backend && npm run dev
cd frontend && npm run dev
```
### Environment Variables
Create a `.env` file from `.env.example`:
```bash
cp .env.example .env
```
Configure the following variables:
- `JWT_SECRET` - JWT signing secret
- `DATABASE_PATH` - SQLite database file location
- `NODE_ENV` - Environment (development/production)
- `CORS_ORIGIN` - Frontend domain
## Deployment
### Production Deployment
```bash
# Build and deploy to production
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# View logs
docker-compose logs -f
# Stop application
docker-compose down
```
### Security Notes
- Use strong JWT secrets in production
- Enable HTTPS in production
- Regular database backups recommended
- Monitor resource usage and logs
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
## License
This project is licensed under the MIT License.
## Support
For issues and questions:
- Create an issue in the repository
- Check the documentation in `/docs`
- Review API documentation at `/docs/API.md`

51
backend/Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# syntax=docker/dockerfile:1
# Builder stage with full Node.js image for compilation
FROM node:20-alpine AS builder
# Install build dependencies for any native modules
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy dependency files first for better layer caching
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy source code
COPY . .
# Note: No build step needed for plain Node.js, but keeping structure for future TypeScript
# Production stage with minimal base image
FROM node:20-alpine AS production
# Install dumb-init for proper signal handling and wget for health checks
RUN apk add --no-cache dumb-init wget
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S appuser -u 1001 -G nodejs
WORKDIR /app
# Copy only production files from builder
COPY --from=builder --chown=appuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:nodejs /app/package.json ./
COPY --from=builder --chown=appuser:nodejs /app/src ./src
# Create directories for data and logs
RUN mkdir -p /app/data /app/logs && \
chown -R appuser:nodejs /app/data /app/logs
USER appuser
EXPOSE 3000
# Health check with timeout and retries
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
# Use dumb-init as PID 1 for proper signal handling
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "src/server.js"]

4606
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
backend/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "edh-stats-backend",
"version": "1.0.0",
"description": "Backend API for EDH/Commander stats tracking application",
"main": "src/server.js",
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "node --test",
"build": "echo 'No build step required for plain Node.js'",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"db:migrate": "node src/database/migrate.js",
"db:seed": "node src/database/seed.js"
},
"dependencies": {
"fastify": "^4.24.3",
"@fastify/cors": "^8.4.0",
"@fastify/jwt": "^7.2.0",
"@fastify/under-pressure": "^8.3.0",
"fastify-metrics": "^10.0.1",
"@fastify/rate-limit": "^9.0.1",
"better-sqlite3": "^9.2.2",
"bcryptjs": "^2.4.3",
"close-with-grace": "^1.2.0",
"dotenv": "^16.3.1",
"zod": "^3.22.4"
},
"devDependencies": {
"eslint": "^8.55.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-import": "^2.29.0"
},
"keywords": [
"magic-the-gathering",
"edh",
"commander",
"statistics",
"fastify",
"sqlite"
],
"author": "EDH Stats App",
"license": "MIT"
}

View File

@@ -0,0 +1,140 @@
import Database from 'better-sqlite3'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
class DatabaseManager {
constructor() {
this.db = null
this.isInitialized = false
}
async initialize() {
if (this.isInitialized) {
return this.db
}
const dbPath = process.env.DATABASE_PATH || join(__dirname, '../../../database/data/edh-stats.db')
try {
// Create database directory if it doesn't exist
const dbDir = dirname(dbPath)
const fs = await import('fs')
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true })
}
this.db = new Database(dbPath)
this.db.pragma('journal_mode = WAL')
this.db.pragma('foreign_keys = ON')
this.db.pragma('query_only = false')
// Run migrations
await this.runMigrations()
this.isInitialized = true
console.log('Database initialized successfully')
return this.db
} catch (error) {
console.error('Failed to initialize database:', error)
throw error
}
}
async runMigrations() {
try {
const migrationPath = join(__dirname, '../database/migrations.sql')
const migrationSQL = readFileSync(migrationPath, 'utf8')
this.db.exec(migrationSQL)
console.log('Database migrations completed')
} catch (error) {
console.error('Failed to run migrations:', error)
throw error
}
}
async seedData() {
try {
const seedPath = join(__dirname, '../database/seeds.sql')
const seedSQL = readFileSync(seedPath, 'utf8')
this.db.exec(seedSQL)
console.log('Database seeding completed')
} catch (error) {
console.error('Failed to seed database:', error)
throw error
}
}
async close() {
if (this.db) {
this.db.close()
this.db = null
this.isInitialized = false
console.log('Database connection closed')
}
}
getDatabase() {
if (!this.isInitialized) {
throw new Error('Database not initialized. Call initialize() first.')
}
return this.db
}
// Helper methods for common operations
prepare(query) {
return this.getDatabase().prepare(query)
}
exec(query) {
return this.getDatabase().exec(query)
}
run(query, params = []) {
return this.getDatabase().prepare(query).run(params)
}
get(query, params = []) {
return this.getDatabase().prepare(query).get(params)
}
all(query, params = []) {
return this.getDatabase().prepare(query).all(params)
}
// Transaction support
transaction(fn) {
return this.getDatabase().transaction(fn)
}
// Health check method
async healthCheck() {
try {
const result = this.get('SELECT 1 as test')
return result?.test === 1
} catch (error) {
console.error('Database health check failed:', error)
return false
}
}
}
// Create and export singleton instance
const dbManager = new DatabaseManager()
export default dbManager
// Helper for async database operations
export const withDatabase = async (callback) => {
const db = await dbManager.initialize()
try {
return await callback(db)
} finally {
// Don't close here, let the manager handle connection lifecycle
}
}

40
backend/src/config/jwt.js Normal file
View File

@@ -0,0 +1,40 @@
import { config } from 'dotenv'
// Load environment variables
config()
export const jwtConfig = {
secret: process.env.JWT_SECRET || 'fallback-secret-for-development',
algorithm: 'HS512',
expiresIn: '15m',
refreshExpiresIn: '7d',
issuer: 'edh-stats',
audience: 'edh-stats-users'
}
export const corsConfig = {
origin: process.env.CORS_ORIGIN || 'http://localhost:80',
credentials: true
}
export const rateLimitConfig = {
max: parseInt(process.env.RATE_LIMIT_MAX) || 100,
timeWindow: parseInt(process.env.RATE_LIMIT_WINDOW) || 15 * 60 * 1000, // 15 minutes
skipOnError: false
}
export const serverConfig = {
port: parseInt(process.env.PORT) || 3000,
host: process.env.HOST || '0.0.0.0',
logger: {
level: process.env.LOG_LEVEL || 'info'
}
}
export const securityConfig = {
bcryptSaltRounds: 12,
passwordMinLength: 8,
usernameMinLength: 3,
commanderNameMinLength: 2,
maxNotesLength: 1000
}

View File

@@ -0,0 +1,82 @@
// Database migration and seed utility
import dbManager from '../config/database.js'
async function runMigrations() {
console.log('Running database migrations...')
try {
await dbManager.initialize()
console.log('Migrations completed successfully!')
} catch (error) {
console.error('Migration failed:', error)
process.exit(1)
} finally {
await dbManager.close()
}
}
async function seedDatabase() {
console.log('Seeding database with sample data...')
try {
await dbManager.initialize()
await dbManager.seedData()
console.log('Database seeded successfully!')
} catch (error) {
console.error('Seeding failed:', error)
process.exit(1)
} finally {
await dbManager.close()
}
}
async function resetDatabase() {
console.log('Resetting database...')
try {
await dbManager.initialize()
// Drop all tables
const db = dbManager.getDatabase()
db.exec(`
DROP TABLE IF EXISTS games;
DROP TABLE IF EXISTS commanders;
DROP TABLE IF EXISTS users;
DROP VIEW IF EXISTS user_stats;
DROP VIEW IF EXISTS commander_stats;
`)
console.log('Database reset completed!')
// Run migrations and seeding
await runMigrations()
await seedDatabase()
} catch (error) {
console.error('Database reset failed:', error)
process.exit(1)
} finally {
await dbManager.close()
}
}
// Command line interface
const command = process.argv[2]
switch (command) {
case 'migrate':
runMigrations()
break
case 'seed':
seedDatabase()
break
case 'reset':
resetDatabase()
break
default:
console.log('Usage:')
console.log(' node migrate.js migrate - Run database migrations')
console.log(' node migrate.js seed - Seed database with sample data')
console.log(' node migrate.js reset - Reset database (drop, migrate, seed)')
process.exit(1)
}

View File

@@ -0,0 +1,118 @@
-- EDH/Commander Stats Tracker Database Schema
-- SQLite database with proper foreign keys and constraints
-- Enable foreign key support
PRAGMA foreign_keys = ON;
-- Users table for authentication
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL CHECK(length(username) >= 3),
password_hash TEXT NOT NULL CHECK(length(password_hash) >= 60),
email TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Commanders table with color identity
CREATE TABLE IF NOT EXISTS commanders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL CHECK(length(name) >= 2),
colors TEXT NOT NULL CHECK(length(colors) >= 2), -- JSON array: ["W", "U", "B", "R", "G"]
user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CHECK(json_valid(colors) = 1)
);
-- Games table with all requested statistics
CREATE TABLE IF NOT EXISTS games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL CHECK(date >= '2020-01-01'),
player_count INTEGER NOT NULL CHECK(player_count >= 2 AND player_count <= 8),
commander_id INTEGER NOT NULL,
won BOOLEAN NOT NULL DEFAULT 0 CHECK(won IN (0, 1)),
rounds INTEGER CHECK(rounds > 0),
starting_player_won BOOLEAN NOT NULL DEFAULT 0 CHECK(starting_player_won IN (0, 1)),
sol_ring_turn_one_won BOOLEAN NOT NULL DEFAULT 0 CHECK(sol_ring_turn_one_won IN (0, 1)),
notes TEXT CHECK(length(notes) <= 1000),
user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (commander_id) REFERENCES commanders(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Performance indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_commanders_user_id ON commanders(user_id);
CREATE INDEX IF NOT EXISTS idx_games_user_id ON games(user_id);
CREATE INDEX IF NOT EXISTS idx_games_commander_id ON games(commander_id);
CREATE INDEX IF NOT EXISTS idx_games_date ON games(date);
CREATE INDEX IF NOT EXISTS idx_games_user_commander ON games(user_id, commander_id);
CREATE INDEX IF NOT EXISTS idx_games_user_date ON games(user_id, date);
-- Triggers to update updated_at timestamps
CREATE TRIGGER IF NOT EXISTS update_users_timestamp
AFTER UPDATE ON users
FOR EACH ROW
BEGIN
UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
CREATE TRIGGER IF NOT EXISTS update_commanders_timestamp
AFTER UPDATE ON commanders
FOR EACH ROW
BEGIN
UPDATE commanders SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
CREATE TRIGGER IF NOT EXISTS update_games_timestamp
AFTER UPDATE ON games
FOR EACH ROW
BEGIN
UPDATE games SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
-- Views for common statistics queries
CREATE VIEW IF NOT EXISTS user_stats AS
SELECT
u.id as user_id,
u.username,
COUNT(DISTINCT c.id) as total_commanders,
COUNT(g.id) as total_games,
SUM(CASE WHEN g.won = 1 THEN 1 ELSE 0 END) as total_wins,
ROUND(
CASE
WHEN COUNT(g.id) > 0 THEN (SUM(CASE WHEN g.won = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(g.id))
ELSE 0
END, 2
) as win_rate,
AVG(g.rounds) as avg_rounds,
MAX(g.date) as last_game_date
FROM users u
LEFT JOIN commanders c ON u.id = c.user_id
LEFT JOIN games g ON u.id = g.user_id
GROUP BY u.id, u.username;
CREATE VIEW IF NOT EXISTS commander_stats AS
SELECT
c.id as commander_id,
c.name,
c.colors,
c.user_id,
COUNT(g.id) as total_games,
SUM(CASE WHEN g.won = 1 THEN 1 ELSE 0 END) as total_wins,
ROUND(
CASE
WHEN COUNT(g.id) > 0 THEN (SUM(CASE WHEN g.won = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(g.id))
ELSE 0
END, 2
) as win_rate,
AVG(g.rounds) as avg_rounds,
SUM(CASE WHEN g.starting_player_won = 1 THEN 1 ELSE 0 END) as starting_player_wins,
SUM(CASE WHEN g.sol_ring_turn_one_won = 1 THEN 1 ELSE 0 END) as sol_ring_wins,
MAX(g.date) as last_played
FROM commanders c
LEFT JOIN games g ON c.id = g.commander_id
GROUP BY c.id, c.name, c.colors, c.user_id;

View File

@@ -0,0 +1,51 @@
-- Sample seed data for development and testing
-- This file contains sample users, commanders, and games
-- Insert sample users (passwords are 'password123' hashed with bcrypt)
INSERT OR IGNORE INTO users (id, username, password_hash, email) VALUES
(1, 'testuser', '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewdBPjRrhSpXqzOa', 'test@example.com'),
(2, 'magictg', '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewdBPjRrhSpXqzOa', 'magic@example.com');
-- Insert sample commanders with various color identities
INSERT OR IGNORE INTO commanders (id, name, colors, user_id) VALUES
-- Mono-colored commanders
(1, 'Urza, Lord High Artificer', '["U"]', 1),
(2, 'Gishath, Sun''s Avatar', '["R","G","W"]', 1),
(3, 'Grim-grin, Corpse-Born', '["U","B"]', 1),
(4, 'Krenko, Mob Boss', '["R"]', 2),
(5, 'Ghave, Guru of Spores', '["W","B","G"]', 2),
(6, 'Narset of the Ancient Way', '["U","R","W"]', 1),
(7, 'Tymna the Weaver', '["W","B"]', 2),
(8, 'Kydele, Chosen of Kruphix', '["U","G"]', 1);
-- Insert sample games with varied statistics
INSERT OR IGNORE INTO games (id, date, player_count, commander_id, won, rounds, starting_player_won, sol_ring_turn_one_won, notes, user_id) VALUES
-- Games for user 1 (testuser)
(1, '2024-01-15', 4, 1, 1, 12, 0, 0, 'Great control game, won with infinite artifacts', 1),
(2, '2024-01-18', 3, 1, 0, 8, 1, 1, 'Lost to aggro, Sol Ring helped but not enough', 1),
(3, '2024-01-22', 4, 2, 1, 15, 0, 1, 'Dinosaur tribal worked perfectly', 1),
(4, '2024-01-25', 5, 3, 0, 10, 0, 0, 'Mana issues all game', 1),
(5, '2024-02-01', 4, 1, 1, 13, 1, 0, 'Close game, won with Brain Freeze', 1),
(6, '2024-02-05', 3, 6, 1, 9, 0, 1, 'Narset enchantments carried the game', 1),
(7, '2024-02-08', 4, 8, 0, 11, 1, 0, 'Lost to tribal deck', 1),
-- Games for user 2 (magictg)
(8, '2024-01-16', 4, 4, 1, 14, 0, 1, 'Krenko went infinite on turn 8', 2),
(9, '2024-01-20', 5, 5, 0, 16, 0, 0, 'Sac outlet deck was too slow', 2),
(10, '2024-01-23', 3, 7, 1, 7, 1, 0, 'Partner commanders worked well', 2),
(11, '2024-01-28', 4, 4, 1, 12, 0, 1, 'Goblins are OP in 1v1', 2),
(12, '2024-02-02', 6, 5, 0, 18, 1, 1, '6 player chaos game, fun but lost', 2);
-- Additional games for more comprehensive statistics
INSERT OR IGNORE INTO games (id, date, player_count, commander_id, won, rounds, starting_player_won, sol_ring_turn_one_won, notes, user_id) VALUES
-- More games for user 1
(13, '2024-02-10', 4, 2, 0, 13, 0, 0, 'Board wiped too many times', 1),
(14, '2024-02-12', 3, 6, 1, 8, 1, 1, 'Narset with turn 1 Sol Ring = win', 1),
(15, '2024-02-15', 4, 3, 1, 11, 0, 0, 'Zombie recursion was key', 1),
(16, '2024-02-18', 5, 1, 0, 17, 1, 1, '5 player game, lost to storm', 1),
-- More games for user 2
(17, '2024-02-05', 4, 7, 0, 10, 0, 0, 'Color screw hurt early game', 2),
(18, '2024-02-09', 3, 4, 0, 9, 0, 1, 'Red deck lost to lifegain', 2),
(19, '2024-02-14', 4, 5, 1, 14, 1, 0, 'Ghave tokens got huge', 2),
(20, '2024-02-17', 4, 7, 1, 12, 0, 1, 'Life gain + card draw = win', 2);

View File

@@ -0,0 +1,121 @@
// JWT Authentication Middleware
import { jwtConfig } from '../config/jwt.js'
// Verify JWT token
export const verifyJWT = async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or expired token'
})
}
}
// Optional JWT verification (doesn't fail if no token)
export const optionalJWT = async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
// Token is optional, so we don't fail
request.user = null
}
}
// Check if user exists and is active
export const validateUser = async (request, reply) => {
try {
const user = request.user
if (!user) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User not authenticated'
})
}
// You could add additional user validation here
// e.g., check if user is active, banned, etc.
} catch (err) {
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to validate user'
})
}
}
// Extract user ID from token and validate resource ownership
export const validateOwnership = (resourceParam = 'id', resourceTable = 'commanders') => {
return async (request, reply) => {
try {
const user = request.user
if (!user) {
return reply.code(401).send({
error: 'Unauthorized',
message: 'User not authenticated'
})
}
const resourceId = request.params[resourceParam]
if (!resourceId) {
return reply.code(400).send({
error: 'Bad Request',
message: 'Resource ID not provided'
})
}
const db = await import('../config/database.js').then(m => m.default)
const database = await db.initialize()
// Check if user owns the resource
const query = `SELECT user_id FROM ${resourceTable} WHERE id = ?`
const resource = database.prepare(query).get([resourceId])
if (!resource) {
return reply.code(404).send({
error: 'Not Found',
message: 'Resource not found'
})
}
if (resource.user_id !== user.id) {
return reply.code(403).send({
error: 'Forbidden',
message: 'Access denied to this resource'
})
}
// Add resource to request object for later use
request.resourceId = resourceId
} catch (err) {
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to validate ownership'
})
}
}
}
// Rate limiting middleware for sensitive endpoints
export const rateLimitAuth = {
config: {
rateLimit: {
max: 5, // 5 requests
timeWindow: '1 minute', // per minute
skipOnError: false
}
}
}
// Rate limiting for general API endpoints
export const rateLimitGeneral = {
config: {
rateLimit: {
max: 100, // 100 requests
timeWindow: '1 minute', // per minute
skipOnError: false
}
}
}

146
backend/src/models/User.js Normal file
View File

@@ -0,0 +1,146 @@
import bcrypt from 'bcryptjs'
import dbManager from '../config/database.js'
class User {
static async create(userData) {
const db = await dbManager.initialize()
const { username, password, email } = userData
// Check if username already exists
const existingUser = db.prepare('SELECT id FROM users WHERE username = ? OR email = ?').get([username, email])
if (existingUser) {
throw new Error('Username or email already exists')
}
// Hash password
const passwordHash = await bcrypt.hash(password, 12)
try {
const result = db.prepare(`
INSERT INTO users (username, password_hash, email)
VALUES (?, ?, ?)
`).run([username, passwordHash, email])
return this.findById(result.lastInsertRowid)
} catch (error) {
throw new Error('Failed to create user')
}
}
static async findById(id) {
const db = await dbManager.initialize()
const user = db.prepare(`
SELECT id, username, email, created_at, updated_at
FROM users
WHERE id = ?
`).get([id])
return user
}
static async findByUsername(username) {
const db = await dbManager.initialize()
const user = db.prepare(`
SELECT id, username, password_hash, email, created_at, updated_at
FROM users
WHERE username = ?
`).get([username])
return user
}
static async findByEmail(email) {
const db = await dbManager.initialize()
const user = db.prepare(`
SELECT id, username, password_hash, email, created_at, updated_at
FROM users
WHERE email = ?
`).get([email])
return user
}
static async verifyPassword(password, hashedPassword) {
return await bcrypt.compare(password, hashedPassword)
}
static async updatePassword(userId, newPassword) {
const db = await dbManager.initialize()
const passwordHash = await bcrypt.hash(newPassword, 12)
const result = db.prepare(`
UPDATE users
SET password_hash = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run([passwordHash, userId])
return result.changes > 0
}
static async updateProfile(userId, profileData) {
const db = await dbManager.initialize()
const { email } = profileData
// Check if email is already taken by another user
if (email) {
const existingUser = db.prepare(`
SELECT id FROM users
WHERE email = ? AND id != ?
`).get([email, userId])
if (existingUser) {
throw new Error('Email already exists')
}
}
const updates = []
const values = []
if (email !== undefined) {
updates.push('email = ?')
values.push(email)
}
if (updates.length === 0) {
throw new Error('No valid fields to update')
}
updates.push('updated_at = CURRENT_TIMESTAMP')
values.push(userId)
const result = db.prepare(`
UPDATE users
SET ${updates.join(', ')}
WHERE id = ?
`).run(values)
return result.changes > 0
}
static async delete(userId) {
const db = await dbManager.initialize()
// This will cascade delete commanders and games due to foreign key constraints
const result = db.prepare('DELETE FROM users WHERE id = ?').run([userId])
return result.changes > 0
}
static async getStats(userId) {
const db = await dbManager.initialize()
const stats = db.prepare(`
SELECT * FROM user_stats WHERE user_id = ?
`).get([userId])
return stats
}
}
export default User

351
backend/src/routes/auth.js Normal file
View File

@@ -0,0 +1,351 @@
// Authentication routes
import { z } from 'zod'
import User from '../models/User.js'
// Validation schemas
const registerSchema = z.object({
username: z.string().min(3).max(50).regex(/^[a-zA-Z0-9_-]+$/, {
message: 'Username can only contain letters, numbers, underscores, and hyphens'
}),
password: z.string().min(8).max(100),
email: z.string().email().optional()
})
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1)
})
const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8).max(100)
})
const updateProfileSchema = z.object({
email: z.string().email().optional()
})
export default async function authRoutes(fastify, options) {
// Register new user
fastify.post('/register', {
config: { rateLimit: { max: 3, timeWindow: '15 minutes' } }
}, async (request, reply) => {
try {
// Validate input
const validatedData = registerSchema.parse(request.body)
// Create user
const user = await User.create(validatedData)
// Generate JWT token
const token = await reply.jwtSign({
id: user.id,
username: user.username
}, {
expiresIn: '15m'
})
reply.code(201).send({
message: 'User registered successfully',
user: {
id: user.id,
username: user.username,
email: user.email,
created_at: user.created_at
},
token
})
} catch (error) {
if (error instanceof z.ZodError) {
reply.code(400).send({
error: 'Validation Error',
message: 'Invalid input data',
details: error.errors.map(e => e.message)
})
} else if (error.message.includes('already exists')) {
reply.code(400).send({
error: 'Registration Failed',
message: error.message
})
} else {
fastify.log.error('Registration error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to register user'
})
}
}
})
// Login user
fastify.post('/login', {
config: { rateLimit: { max: 10, timeWindow: '15 minutes' } }
}, async (request, reply) => {
try {
const { username, password } = loginSchema.parse(request.body)
// Find user
const user = await User.findByUsername(username)
if (!user) {
reply.code(401).send({
error: 'Authentication Failed',
message: 'Invalid username or password'
})
return
}
// Verify password
const isValidPassword = await User.verifyPassword(password, user.password_hash)
if (!isValidPassword) {
reply.code(401).send({
error: 'Authentication Failed',
message: 'Invalid username or password'
})
return
}
// Generate JWT token
const token = await reply.jwtSign({
id: user.id,
username: user.username
}, {
expiresIn: '15m'
})
reply.send({
message: 'Login successful',
user: {
id: user.id,
username: user.username,
email: user.email
},
token
})
} catch (error) {
if (error instanceof z.ZodError) {
reply.code(400).send({
error: 'Validation Error',
message: 'Invalid input data',
details: error.errors.map(e => e.message)
})
} else {
fastify.log.error('Login error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to authenticate user'
})
}
}
})
// Refresh token
fastify.post('/refresh', {
config: {
rateLimit: { max: 20, timeWindow: '15 minutes' }
}
}, async (request, reply) => {
try {
await request.jwtVerify()
const user = await User.findById(request.user.id)
if (!user) {
reply.code(401).send({
error: 'Authentication Failed',
message: 'User not found'
})
return
}
// Generate new token
const token = await reply.jwtSign({
id: user.id,
username: user.username
}, {
expiresIn: '15m'
})
reply.send({
message: 'Token refreshed successfully',
token
})
} catch (error) {
reply.code(401).send({
error: 'Authentication Failed',
message: 'Invalid or expired token'
})
}
})
// Get current user profile
fastify.get('/me', {
preHandler: [async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or expired token'
})
}
}]
}, async (request, reply) => {
try {
const user = await User.findById(request.user.id)
if (!user) {
reply.code(404).send({
error: 'Not Found',
message: 'User not found'
})
return
}
reply.send({
user: {
id: user.id,
username: user.username,
email: user.email,
created_at: user.created_at
}
})
} catch (error) {
fastify.log.error('Get profile error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to get user profile'
})
}
})
// Update user profile
fastify.patch('/me', {
preHandler: [async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or expired token'
})
}
}]
}, async (request, reply) => {
try {
const validatedData = updateProfileSchema.parse(request.body)
const updated = await User.updateProfile(request.user.id, validatedData)
if (!updated) {
reply.code(400).send({
error: 'Update Failed',
message: 'No valid fields to update'
})
return
}
const user = await User.findById(request.user.id)
reply.send({
message: 'Profile updated successfully',
user: {
id: user.id,
username: user.username,
email: user.email,
updated_at: user.updated_at
}
})
} catch (error) {
if (error instanceof z.ZodError) {
reply.code(400).send({
error: 'Validation Error',
message: 'Invalid input data',
details: error.errors.map(e => e.message)
})
} else if (error.message.includes('already exists')) {
reply.code(400).send({
error: 'Update Failed',
message: error.message
})
} else {
fastify.log.error('Update profile error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to update profile'
})
}
}
})
// Change password
fastify.post('/change-password', {
preHandler: [async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or expired token'
})
}
}],
config: { rateLimit: { max: 3, timeWindow: '1 hour' } }
}, async (request, reply) => {
try {
const { currentPassword, newPassword } = changePasswordSchema.parse(request.body)
// Verify current password
const user = await User.findByUsername(request.user.username)
if (!user) {
reply.code(404).send({
error: 'Not Found',
message: 'User not found'
})
return
}
const isValidPassword = await User.verifyPassword(currentPassword, user.password_hash)
if (!isValidPassword) {
reply.code(401).send({
error: 'Authentication Failed',
message: 'Current password is incorrect'
})
return
}
// Update password
const updated = await User.updatePassword(request.user.id, newPassword)
if (!updated) {
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to update password'
})
return
}
reply.send({
message: 'Password changed successfully'
})
} catch (error) {
if (error instanceof z.ZodError) {
reply.code(400).send({
error: 'Validation Error',
message: 'Invalid input data',
details: error.errors.map(e => e.message)
})
} else {
fastify.log.error('Change password error:', error)
reply.code(500).send({
error: 'Internal Server Error',
message: 'Failed to change password'
})
}
}
})
}

287
backend/src/server.js Normal file
View File

@@ -0,0 +1,287 @@
import fastify from 'fastify'
import cors from '@fastify/cors'
import jwt from '@fastify/jwt'
import underPressure from '@fastify/under-pressure'
import rateLimit from '@fastify/rate-limit'
// import metrics from 'fastify-metrics' // Commented out for now
import closeWithGrace from 'close-with-grace'
// Import configurations
import { jwtConfig, corsConfig, rateLimitConfig, serverConfig } from './config/jwt.js'
import dbManager from './config/database.js'
// Import routes
import authRoutes from './routes/auth.js'
// Build the Fastify application
async function build() {
const app = fastify({
logger: serverConfig.logger,
trustProxy: true
})
// Register plugins
await app.register(cors, corsConfig)
await app.register(jwt, {
secret: jwtConfig.secret
})
// await app.register(underPressure, {
// maxEventLoopDelay: 1000,
// maxHeapUsedBytes: 0.9 * 1024 * 1024 * 1024, // 1GB
// maxRssBytes: 0.9 * 1024 * 1024 * 1024, // 1GB
// healthCheck: async () => {
// try {
// const db = await dbManager.initialize()
// return await db.healthCheck()
// } catch (error) {
// return false
// }
// },
// healthCheckInterval: 10000,
// exposeStatusRoute: '/under-pressure'
// })
// await app.register(rateLimit, {
// max: rateLimitConfig.max,
// timeWindow: rateLimitConfig.timeWindow,
// skipOnError: rateLimitConfig.skipOnError,
// keyGenerator: (request) => {
// return request.ip || request.headers['x-forwarded-for'] || 'unknown'
// },
// errorResponseBuilder: (request, context) => ({
// code: 'RATE_LIMIT_EXCEEDED',
// error: 'Rate limit exceeded',
// message: `Too many requests. Try again in ${Math.round(context.ttl / 1000)} seconds.`,
// retryAfter: context.ttl
// })
// })
// Metrics (optional, for monitoring) - disabled for now
// if (process.env.METRICS_ENABLED === 'true') {
// await app.register(metrics, {
// endpoint: '/metrics'
// })
// }
// Authentication decorator
app.decorate('authenticate', async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or expired token'
})
}
})
// Health check endpoint
app.get('/api/health', async (request, reply) => {
try {
const db = await dbManager.initialize()
const dbHealthy = await db.healthCheck()
const status = {
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory: process.memoryUsage(),
database: dbHealthy ? 'connected' : 'disconnected'
}
if (dbHealthy) {
return status
} else {
reply.code(503).send({
...status,
status: 'unhealthy'
})
}
} catch (error) {
app.log.error('Health check failed:', error)
reply.code(503).send({
status: 'unhealthy',
timestamp: new Date().toISOString(),
error: 'Health check failed'
})
}
})
// API routes
await app.register(authRoutes, { prefix: '/api/auth' })
// Commander routes (to be implemented)
app.get('/api/commanders', { preHandler: [async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or expired token'
})
}
}] }, async (request, reply) => {
return { message: 'Commander routes coming soon!' }
})
// Games routes (to be implemented)
app.get('/api/games', { preHandler: [async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or expired token'
})
}
}] }, async (request, reply) => {
return { message: 'Games routes coming soon!' }
})
// Statistics routes (to be implemented)
app.get('/api/stats', { preHandler: [async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid or expired token'
})
}
}] }, async (request, reply) => {
return { message: 'Statistics routes coming soon!' }
})
// Test endpoint
app.route({
method: 'GET',
url: '/test',
handler: async (request, reply) => {
return { message: 'Test endpoint works!' }
}
})
// Root endpoint
app.get('/', async (request, reply) => {
return {
message: 'EDH/Commander Stats API',
version: '1.0.0',
status: 'running'
}
})
// 404 handler
app.setNotFoundHandler(async (request, reply) => {
reply.code(404).send({
error: 'Not Found',
message: 'The requested resource was not found'
})
})
// Error handler
app.setErrorHandler(async (error, request, reply) => {
app.log.error(error)
// Handle validation errors
if (error.validation) {
reply.code(400).send({
error: 'Validation Error',
message: 'Request validation failed',
details: error.validation
})
return
}
// Handle JWT errors
if (error.code === 'FST_JWT_NO_AUTHORIZATION_IN_HEADER') {
reply.code(401).send({
error: 'Unauthorized',
message: 'Authorization token required'
})
return
}
if (error.code === 'FST_JWT_AUTHORIZATION_TOKEN_EXPIRED') {
reply.code(401).send({
error: 'Unauthorized',
message: 'Token has expired'
})
return
}
if (error.code === 'FST_JWT_AUTHORIZATION_TOKEN_INVALID') {
reply.code(401).send({
error: 'Unauthorized',
message: 'Invalid token'
})
return
}
// Handle rate limit errors
if (error.statusCode === 429) {
reply.code(429).send({
error: 'Too Many Requests',
message: error.message
})
return
}
// Generic error response
reply.code(500).send({
error: 'Internal Server Error',
message: 'An unexpected error occurred'
})
})
// Graceful shutdown
closeWithGrace({ delay: 5000 }, async function ({ err, signal }) {
if (err) {
app.log.error({ err }, 'Error during shutdown')
} else {
app.log.info({ signal }, 'Received signal')
}
try {
await dbManager.close()
await app.close()
process.exit(0)
} catch (shutdownError) {
app.log.error(shutdownError, 'Error during shutdown')
process.exit(1)
}
})
return app
}
// Start the server
async function start() {
try {
const app = await build()
// Initialize database
await dbManager.initialize()
const port = serverConfig.port
const host = serverConfig.host
await app.listen({ port, host })
app.log.info(`Server listening on http://${host}:${port}`)
app.log.info(`Health check available at http://${host}:${port}/api/health`)
return app
} catch (error) {
console.error('Failed to start server:', error)
process.exit(1)
}
}
// Start server if this file is run directly
if (import.meta.url === `file://${process.argv[1]}`) {
start()
}
export default build

18
backend/test-server.js Normal file
View File

@@ -0,0 +1,18 @@
import fastify from 'fastify'
const app = fastify({ logger: true })
app.get('/', async (request, reply) => {
return { hello: 'world' }
})
app.post('/test', async (request, reply) => {
return { received: request.body }
})
app.listen({ port: 3001 }, (err) => {
if (err) {
app.log.error(err)
process.exit(1)
}
})

75
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,75 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
target: production
environment:
- NODE_ENV=production
- DATABASE_PATH=/app/database/data/edh-stats.db
- JWT_SECRET_FILE=/run/secrets/jwt_secret
- CORS_ORIGIN=${CORS_ORIGIN:-https://yourdomain.com}
- LOG_LEVEL=warn
- RATE_LIMIT_WINDOW=15
- RATE_LIMIT_MAX=100
volumes:
- sqlite_data:/app/database/data
- app_logs:/app/logs
secrets:
- jwt_secret
restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
reservations:
memory: 256M
cpus: '0.25'
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- edh-stats-network
stop_grace_period: 30s
frontend:
image: nginx:alpine
volumes:
- ./frontend/nginx.prod.conf:/etc/nginx/nginx.conf:ro
- ./frontend/public:/usr/share/nginx/html:ro
ports:
- "80:80"
- "443:443"
restart: unless-stopped
networks:
- edh-stats-network
# Optional: Add reverse proxy for SSL termination
# nginx-proxy:
# image: nginx:alpine
# ports:
# - "443:443"
# volumes:
# - ./ssl:/etc/nginx/ssl:ro
# depends_on:
# - frontend
volumes:
sqlite_data:
driver: local
app_logs:
driver: local
secrets:
jwt_secret:
external: true
networks:
edh-stats-network:
driver: bridge

53
docker-compose.yml Normal file
View File

@@ -0,0 +1,53 @@
# Docker Compose configuration for EDH Stats Tracker
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
target: production
ports:
- "3002:3000"
environment:
- NODE_ENV=development
- DATABASE_PATH=/app/database/data/edh-stats.db
- JWT_SECRET=dev-jwt-secret-key-change-in-production
- CORS_ORIGIN=http://localhost
- LOG_LEVEL=info
volumes:
- sqlite_data:/app/database/data
- ./backend/src:/app/src
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- edh-stats-network
frontend:
image: nginx:alpine
ports:
- "8081:80"
volumes:
- ./frontend/nginx.conf:/etc/nginx/nginx.conf:ro
- ./frontend/public:/usr/share/nginx/html:ro
depends_on:
- backend
restart: unless-stopped
networks:
- edh-stats-network
volumes:
sqlite_data:
driver: local
driver_opts:
type: none
o: bind
device: ${PWD}/database/data
networks:
edh-stats-network:
driver: bridge

95
frontend/css/input.css Normal file
View File

@@ -0,0 +1,95 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom base styles */
@layer base {
body {
@apply font-sans antialiased;
}
h1, h2, h3, h4, h5, h6 {
@apply font-mtg text-gray-800;
}
}
/* Custom component styles */
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-primary {
@apply bg-edh-primary text-white hover:bg-edh-secondary focus:ring-edh-accent;
}
.btn-secondary {
@apply bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500;
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
}
.form-input {
@apply w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-edh-accent focus:border-edh-accent;
}
.form-label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
.form-error {
@apply text-red-600 text-sm mt-1;
}
.card {
@apply bg-white rounded-lg shadow-md border border-gray-200 p-6;
}
.stat-card {
@apply bg-gradient-to-br from-edh-primary to-edh-secondary text-white rounded-lg shadow-lg p-6;
}
/* MTG Color Identity Styles */
.color-w { @apply bg-mtg-white border-2 border-gray-400; }
.color-u { @apply bg-mtg-blue border-2 border-blue-800; }
.color-b { @apply bg-mtg-black border-2 border-gray-900; }
.color-r { @apply bg-mtg-red border-2 border-red-800; }
.color-g { @apply bg-mtg-green border-2 border-green-800; }
.color-c { @apply bg-mtg-colorless border-2 border-gray-600; }
.color-selected {
@apply ring-2 ring-offset-2 ring-edh-accent transform scale-110;
}
/* Animation classes */
.slide-up-enter {
@apply opacity-0 transform translate-y-4;
}
.slide-up-enter-active {
@apply opacity-100 transform translate-y-0 transition-all duration-300 ease-out;
}
}
/* Custom utilities */
@layer utilities {
.text-shadow {
text-shadow: 2px 2px 4px rgba(0,0,0,0.1);
}
.glass-effect {
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.chart-container {
@apply relative h-64 w-full;
}
.loading-spinner {
@apply animate-spin rounded-full border-4 border-gray-200 border-t-edh-accent;
}
}

1368
frontend/css/styles.css Normal file

File diff suppressed because it is too large Load Diff

82
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,82 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Security headers (commented out for debugging)
# add_header X-Frame-Options "SAMEORIGIN" always;
# add_header X-XSS-Protection "1; mode=block" always;
# add_header X-Content-Type-Options "nosniff" always;
# add_header Referrer-Policy "no-referrer-when-downgrade" always;
# add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# Handle static assets with caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Handle API proxy
location /api/ {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Handle all routes with SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Error pages
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}

111
frontend/nginx.prod.conf Normal file
View File

@@ -0,0 +1,111 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log error;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=static:10m rate=30r/s;
server {
listen 80;
server_name _;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SSL configuration (add your SSL certificates)
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
# ssl_prefer_server_ciphers off;
# Enhanced security headers
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://your-api-domain.com" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Rate limited API proxy
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
}
# Rate limited static files
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
limit_req zone=static burst=50 nodelay;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Main application routes
location / {
limit_req zone=static burst=10 nodelay;
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Error pages
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
}

1237
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "edh-stats-frontend",
"version": "1.0.0",
"description": "Frontend for EDH/Commander stats tracking application",
"type": "module",
"scripts": {
"dev": "echo 'Static files - use nginx server' && python3 -m http.server 8080",
"build-css": "tailwindcss -i ./css/input.css -o ./css/styles.css --watch",
"build-css:prod": "tailwindcss -i ./css/input.css -o ./css/styles.css --minify"
},
"dependencies": {
"alpinejs": "^3.13.3",
"chart.js": "^4.4.1"
},
"devDependencies": {
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32"
},
"keywords": [
"alpinejs",
"tailwindcss",
"magic-the-gathering",
"edh",
"commander"
],
"author": "EDH Stats App",
"license": "MIT"
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

265
frontend/public/dashboard.html Executable file
View File

@@ -0,0 +1,265 @@
<!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="stylesheet" href="/css/styles.css">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body class="h-full" x-data="app()">
<!-- Navigation Header -->
<header class="bg-edh-primary 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="hover:text-edh-accent transition-colors">Dashboard</a>
<a href="/commanders.html" class="hover:text-edh-accent transition-colors">Commanders</a>
<a href="/games.html" class="hover:text-edh-accent transition-colors">Log Game</a>
<a href="/stats.html" class="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-edh-secondary 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="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Profile</a>
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Settings</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="hover:text-edh-accent transition-colors py-2">Dashboard</a>
<a href="/commanders.html" class="hover:text-edh-accent transition-colors py-2">Commanders</a>
<a href="/games.html" class="hover:text-edh-accent transition-colors py-2">Log Game</a>
<a href="/stats.html" class="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 -->
<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">Commanders</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>
<!-- 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.player_count + ' players'"></p>
</div>
<div>
<p class="font-medium" x-text="game.commander_name"></p>
<div class="flex space-x-1">
<template x-for="color in JSON.parse(game.colors)">
<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.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="flex space-x-1">
<template x-for="color in JSON.parse(commander.colors)">
<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.total_games + ' games'"></p>
</div>
</div>
<div class="text-right">
<p class="font-bold text-edh-primary" x-text="commander.win_rate + '%'"></p>
<p class="text-xs text-gray-500">
<span x-text="Math.round(commander.avg_rounds) + ' avg rounds'"></span>
</p>
</div>
</div>
</template>
<div x-show="topCommanders.length === 0" class="text-center py-8 text-gray-500">
<p>No commanders added yet.</p>
<a href="/commanders.html" class="text-edh-accent hover:text-edh-primary">Add your first commander</a>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="mt-8 grid grid-cols-1 md:grid-cols-3 gap-6">
<button @click="startRoundCounter()" class="card hover:shadow-lg transition-shadow text-center">
<svg class="w-12 h-12 mx-auto mb-4 text-edh-accent" 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>
<h3 class="font-semibold mb-2">Start Round Counter</h3>
<p class="text-sm text-gray-600">Track rounds for your current game</p>
</button>
<a href="/games.html" class="card hover:shadow-lg transition-shadow text-center block">
<svg class="w-12 h-12 mx-auto mb-4 text-edh-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<h3 class="font-semibold mb-2">Log Game</h3>
<p class="text-sm text-gray-600">Record your latest game results</p>
</a>
<a href="/stats.html" class="card hover:shadow-lg transition-shadow text-center block">
<svg class="w-12 h-12 mx-auto mb-4 text-edh-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
<h3 class="font-semibold mb-2">View Statistics</h3>
<p class="text-sm text-gray-600">Analyze your performance trends</p>
</a>
</div>
</main>
<!-- 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-500 text-sm mt-2">Built with Fastify, SQLite, and Alpine.js</p>
</div>
</footer>
<!-- Scripts -->
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

217
frontend/public/js/app.js Executable file
View File

@@ -0,0 +1,217 @@
// 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()
},
async checkAuth() {
const token = localStorage.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')
if (window.location.pathname !== '/login.html') {
window.location.href = '/login.html'
}
}
} catch (error) {
console.error('Auth check failed:', error)
localStorage.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')
// Load user stats
const statsResponse = await fetch('/api/stats/overview', {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (statsResponse.ok) {
const statsData = await statsResponse.json()
this.stats = {
totalGames: statsData.total_games || 0,
winRate: Math.round(statsData.win_rate || 0),
totalCommanders: statsData.total_commanders || 0,
avgRounds: Math.round(statsData.avg_rounds || 0)
}
}
// 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
const commandersResponse = await fetch('/api/commanders?limit=5&sort=win_rate', {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (commandersResponse.ok) {
const commandersData = await commandersResponse.json()
this.topCommanders = commandersData.commanders || []
}
} catch (error) {
console.error('Failed to load dashboard data:', error)
}
},
logout() {
localStorage.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')
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)
})

234
frontend/public/js/auth.js Executable file
View File

@@ -0,0 +1,234 @@
// 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
})
})
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: ''
},
errors: {},
showPassword: false,
showConfirmPassword: 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 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 = ''
}
},
async handleRegister() {
// Validate all fields
this.validateUsername()
this.validateEmail()
this.validatePassword()
this.validateConfirmPassword()
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 || null,
password: this.formData.password
})
})
const data = await response.json()
if (response.ok) {
// Store token and redirect
localStorage.setItem('edh-stats-token', data.token)
window.location.href = '/dashboard.html'
} 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
}

167
frontend/public/login.html Executable file
View File

@@ -0,0 +1,167 @@
<!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">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body class="h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8" x-data="loginForm()">
<!-- 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">
<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>
<!-- Test Credentials (for development) -->
<div class="card bg-blue-50 border-blue-200">
<h3 class="text-sm font-medium text-blue-800 mb-2">Test Credentials (Development)</h3>
<div class="text-xs text-blue-700 space-y-1">
<p><strong>Username:</strong> testuser</p>
<p><strong>Password:</strong> password123</p>
<p><strong>Username:</strong> magictg</p>
<p><strong>Password:</strong> password123</p>
</div>
</div>
</div>
<!-- Scripts -->
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="/js/auth.js"></script>
</body>
</html>

View File

@@ -0,0 +1,73 @@
<!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 - Application Status</title>
<style>
.status { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
.success { color: #10b981; background: #d4edda; padding: 20px; border-radius: 8px; margin: 10px 0; }
.endpoint { background: #f8f9fa; border: 1px solid #e9ecef; padding: 15px; margin: 10px 0; border-radius: 5px; }
.endpoint a { color: #007bff; text-decoration: none; font-weight: bold; }
.endpoint a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="status">
<h1>🎉 EDH Stats Tracker - Successfully Deployed!</h1>
<div class="success">
<h2>✅ Application Status: RUNNING</h2>
<p>Your EDH/Commander stats tracking application has been successfully deployed and is running!</p>
</div>
<h2>📋 Access Information</h2>
<div class="endpoint">
<strong>Frontend Application:</strong>
<a href="http://localhost:8081/login.html">http://localhost:8081</a>
<ul>
<li><a href="http://localhost:8081/login.html">Login Page</a></li>
<li><a href="http://localhost:8081/dashboard.html">Dashboard</a></li>
</ul>
</div>
<div class="endpoint">
<strong>Backend API:</strong>
<a href="http://localhost:3002/api/health">http://localhost:3002</a>
<ul>
<li><a href="http://localhost:3002/api/health">Health Check</a></li>
<li><a href="http://localhost:3002/">Root API</a></li>
</ul>
</div>
<h2>🔑 Test Credentials</h2>
<div class="endpoint">
<strong>Username:</strong> newuser
<br><strong>Password:</strong> password123
<br><small>Use these credentials to test the application</small>
</div>
<h2>🎯 What's Implemented</h2>
<div class="endpoint">
<ul>
<li>✅ User Registration & Login with JWT Authentication</li>
<li>✅ SQLite Database with Sample Data</li>
<li>✅ Secure API with Rate Limiting</li>
<li>✅ Responsive Frontend with Alpine.js & Tailwind CSS</li>
<li>✅ MTG Color Identity Support</li>
<li>✅ Round Counter Utility</li>
<li>✅ Docker Containerization</li>
</ul>
</div>
<h2>📝 Next Development Steps</h2>
<div class="endpoint">
<ul>
<li>🔄 Commander Management (CRUD Operations)</li>
<li>🎲 Game Logging Interface with Statistics</li>
<li>📊 Statistics Dashboard with Charts</li>
<li>🎨 Additional UI Polish & Features</li>
</ul>
</div>
</div>
</body>
</html>

10
frontend/public/test.html Normal file
View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
</head>
<body>
<h1>Hello EDH Stats!</h1>
<p>Frontend is working!</p>
</body>
</html>

View File

@@ -0,0 +1,46 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./public/**/*.html",
"./public/**/*.js",
"./js/**/*.js"
],
theme: {
extend: {
colors: {
'mtg-white': '#F0E6D2',
'mtg-blue': '#0E68AB',
'mtg-black': '#2C2B2D',
'mtg-red': '#C44536',
'mtg-green': '#5A7A3B',
'mtg-gold': '#C8B991',
'mtg-colorless': '#BAB0AC',
'edh-primary': '#1a365d',
'edh-secondary': '#2c5282',
'edh-accent': '#3182ce',
},
fontFamily: {
'mtg': ['Georgia', 'serif'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
}
}
},
},
plugins: [
// require('@tailwindcss/forms'),
// require('@tailwindcss/typography'),
],
}