Add EDH Stats backend and frontend scaffolding
This commit is contained in:
29
.env.example
Normal file
29
.env.example
Normal 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
102
.gitignore
vendored
Normal 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
185
README.md
Normal 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
51
backend/Dockerfile
Normal 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
4606
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
backend/package.json
Normal file
45
backend/package.json
Normal 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"
|
||||
}
|
||||
140
backend/src/config/database.js
Normal file
140
backend/src/config/database.js
Normal 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
40
backend/src/config/jwt.js
Normal 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
|
||||
}
|
||||
82
backend/src/database/migrate.js
Normal file
82
backend/src/database/migrate.js
Normal 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)
|
||||
}
|
||||
118
backend/src/database/migrations.sql
Normal file
118
backend/src/database/migrations.sql
Normal 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;
|
||||
51
backend/src/database/seeds.sql
Normal file
51
backend/src/database/seeds.sql
Normal 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);
|
||||
121
backend/src/middleware/auth.js
Normal file
121
backend/src/middleware/auth.js
Normal 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
146
backend/src/models/User.js
Normal 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
351
backend/src/routes/auth.js
Normal 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
287
backend/src/server.js
Normal 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
18
backend/test-server.js
Normal 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
75
docker-compose.prod.yml
Normal 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
53
docker-compose.yml
Normal 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
95
frontend/css/input.css
Normal 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
1368
frontend/css/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
82
frontend/nginx.conf
Normal file
82
frontend/nginx.conf
Normal 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
111
frontend/nginx.prod.conf
Normal 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
1237
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal 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"
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
265
frontend/public/dashboard.html
Executable file
265
frontend/public/dashboard.html
Executable 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
217
frontend/public/js/app.js
Executable 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
234
frontend/public/js/auth.js
Executable 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
167
frontend/public/login.html
Executable 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>
|
||||
73
frontend/public/status.html
Normal file
73
frontend/public/status.html
Normal 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
10
frontend/public/test.html
Normal 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>
|
||||
46
frontend/tailwind.config.js
Normal file
46
frontend/tailwind.config.js
Normal 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'),
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user