Test
This commit is contained in:
20
.cursor/argv.json
Normal file
20
.cursor/argv.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// This configuration file allows you to pass permanent command line arguments to VS Code.
|
||||||
|
// Only a subset of arguments is currently supported to reduce the likelihood of breaking
|
||||||
|
// the installation.
|
||||||
|
//
|
||||||
|
// PLEASE DO NOT CHANGE WITHOUT UNDERSTANDING THE IMPACT
|
||||||
|
//
|
||||||
|
// NOTE: Changing this file requires a restart of VS Code.
|
||||||
|
{
|
||||||
|
// Use software rendering instead of hardware accelerated rendering.
|
||||||
|
// This can help in cases where you see rendering issues in VS Code.
|
||||||
|
// "disable-hardware-acceleration": true,
|
||||||
|
|
||||||
|
// Allows to disable crash reporting.
|
||||||
|
// Should restart the app if the value is changed.
|
||||||
|
"enable-crash-reporter": true,
|
||||||
|
|
||||||
|
// Unique id used for correlating crash reports sent from this instance.
|
||||||
|
// Do not edit this value.
|
||||||
|
"crash-reporter-id": "1babcb0f-de8a-4d94-b169-32f2e2f6f233"
|
||||||
|
}
|
||||||
1
.cursor/extensions/extensions.json
Normal file
1
.cursor/extensions/extensions.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
# Keep environment variables out of version control
|
||||||
|
.env
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
94
docker/.dockerignore
Normal file
94
docker/.dockerignore
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Production builds
|
||||||
|
tournament-frontend/build
|
||||||
|
tournament-frontend/dist
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
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
|
||||||
43
docker/Dockerfile
Normal file
43
docker/Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Use Node.js 20 as base image
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY tournament-frontend/package*.json ./tournament-frontend/
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
RUN cd tournament-frontend && npm ci
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the frontend
|
||||||
|
RUN cd tournament-frontend && npm run build
|
||||||
|
|
||||||
|
# Copy built frontend to backend public directory
|
||||||
|
RUN mkdir -p public && cp -r tournament-frontend/build/* public/
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs
|
||||||
|
RUN adduser -S nodejs -u 1001
|
||||||
|
|
||||||
|
# Change ownership of the app directory
|
||||||
|
RUN chown -R nodejs:nodejs /app
|
||||||
|
USER nodejs
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://localhost:4000/', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["node", "index.js"]
|
||||||
137
docker/ENVIRONMENT.md
Normal file
137
docker/ENVIRONMENT.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Environment Configuration Guide
|
||||||
|
|
||||||
|
## Development Environment
|
||||||
|
|
||||||
|
For development, use the default `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Default Credentials (Development)
|
||||||
|
- **Admin Username**: `admin`
|
||||||
|
- **Admin Password**: `admin123`
|
||||||
|
- **Database**: `tournament`
|
||||||
|
- **Database User**: `tournament_user`
|
||||||
|
- **Database Password**: `tournament_password`
|
||||||
|
|
||||||
|
## Production Environment
|
||||||
|
|
||||||
|
For production deployment, use `docker-compose.prod.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables (Production)
|
||||||
|
|
||||||
|
Create a `.env` file in the docker directory with the following variables:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database Configuration
|
||||||
|
POSTGRES_PASSWORD=your_secure_postgres_password_here
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
JWT_SECRET=your_super_secure_jwt_secret_here_make_it_long_and_random
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=your_secure_admin_password_here
|
||||||
|
ADMIN_EMAIL=admin@yourdomain.com
|
||||||
|
|
||||||
|
# Optional: Custom domain for SSL
|
||||||
|
DOMAIN=yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Checklist for Production
|
||||||
|
|
||||||
|
- [ ] Change `POSTGRES_PASSWORD` to a strong password
|
||||||
|
- [ ] Change `JWT_SECRET` to a long, random string
|
||||||
|
- [ ] Change `ADMIN_PASSWORD` to a secure password
|
||||||
|
- [ ] Update `ADMIN_EMAIL` to a valid email address
|
||||||
|
- [ ] Configure SSL certificates (optional)
|
||||||
|
- [ ] Set up proper firewall rules
|
||||||
|
- [ ] Configure backup strategy
|
||||||
|
- [ ] Set up monitoring and logging
|
||||||
|
|
||||||
|
### SSL Configuration
|
||||||
|
|
||||||
|
To enable HTTPS:
|
||||||
|
|
||||||
|
1. Create an `ssl` directory in the docker folder
|
||||||
|
2. Add your SSL certificates:
|
||||||
|
- `ssl/cert.pem` - SSL certificate
|
||||||
|
- `ssl/key.pem` - Private key
|
||||||
|
3. Uncomment the HTTPS section in `nginx.conf`
|
||||||
|
4. Update the domain name in `nginx.conf`
|
||||||
|
|
||||||
|
### Backup and Restore
|
||||||
|
|
||||||
|
#### Database Backup
|
||||||
|
```bash
|
||||||
|
docker-compose exec postgres pg_dump -U tournament_user tournament > backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Database Restore
|
||||||
|
```bash
|
||||||
|
docker-compose exec -T postgres psql -U tournament_user tournament < backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Full Application Backup
|
||||||
|
```bash
|
||||||
|
# Backup volumes
|
||||||
|
docker run --rm -v tournament_postgres_data:/data -v $(pwd):/backup alpine tar czf /backup/postgres_backup.tar.gz -C /data .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
#### Health Checks
|
||||||
|
- Application: `http://localhost/health`
|
||||||
|
- Database: Built into docker-compose
|
||||||
|
- Nginx: Built into docker-compose
|
||||||
|
|
||||||
|
#### Logs
|
||||||
|
```bash
|
||||||
|
# View all logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# View specific service logs
|
||||||
|
docker-compose logs -f tournament-app
|
||||||
|
docker-compose logs -f postgres
|
||||||
|
docker-compose logs -f nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scaling
|
||||||
|
|
||||||
|
The application can be scaled horizontally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --scale tournament-app=3
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: You'll need to configure a load balancer (like nginx) to distribute traffic across multiple instances.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
#### Common Issues
|
||||||
|
|
||||||
|
1. **Port conflicts**: Change ports in docker-compose.yml
|
||||||
|
2. **Database connection issues**: Check if PostgreSQL is running and healthy
|
||||||
|
3. **Build failures**: Ensure all dependencies are properly installed
|
||||||
|
4. **Permission issues**: Check file permissions and ownership
|
||||||
|
|
||||||
|
#### Debug Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check container status
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Access application shell
|
||||||
|
docker-compose exec tournament-app sh
|
||||||
|
|
||||||
|
# Check database connection
|
||||||
|
docker-compose exec postgres psql -U tournament_user -d tournament
|
||||||
|
|
||||||
|
# View nginx configuration
|
||||||
|
docker-compose exec nginx nginx -t
|
||||||
|
```
|
||||||
165
docker/README.md
Normal file
165
docker/README.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Tournament Application Docker Setup
|
||||||
|
|
||||||
|
This directory contains the Docker configuration for the Tournament Application.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `Dockerfile` - Multi-stage build for the application
|
||||||
|
- `docker-compose.yml` - Complete stack with PostgreSQL and Nginx
|
||||||
|
- `nginx.conf` - Nginx reverse proxy configuration
|
||||||
|
- `.dockerignore` - Excludes unnecessary files from build context
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker
|
||||||
|
- Docker Compose
|
||||||
|
|
||||||
|
### Running the Application
|
||||||
|
|
||||||
|
1. **Start the complete stack:**
|
||||||
|
```bash
|
||||||
|
cd docker
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Access the application:**
|
||||||
|
- Frontend: http://localhost:80
|
||||||
|
- API: http://localhost:4000
|
||||||
|
- Database: localhost:5432
|
||||||
|
|
||||||
|
3. **View logs:**
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Stop the application:**
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
### Tournament App (Port 4000)
|
||||||
|
- Node.js backend with Express
|
||||||
|
- React frontend (built and served by backend)
|
||||||
|
- Prisma ORM for database operations
|
||||||
|
- Socket.IO for real-time updates
|
||||||
|
|
||||||
|
### PostgreSQL (Port 5432)
|
||||||
|
- Database: `tournament`
|
||||||
|
- User: `tournament_user`
|
||||||
|
- Password: `tournament_password`
|
||||||
|
|
||||||
|
### Nginx (Port 80/443)
|
||||||
|
- Reverse proxy for the application
|
||||||
|
- Static file serving
|
||||||
|
- SSL termination (configured but disabled by default)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
The application uses the following environment variables:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgresql://tournament_user:tournament_password@postgres:5432/tournament
|
||||||
|
PORT=4000
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=admin123
|
||||||
|
ADMIN_EMAIL=admin@tournament.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Building the Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docker
|
||||||
|
docker build -t tournament-app -f Dockerfile ..
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running with Custom Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.yml -f docker-compose.override.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
1. **Change default passwords:**
|
||||||
|
- Update `ADMIN_PASSWORD` in docker-compose.yml
|
||||||
|
- Update `JWT_SECRET` to a strong random string
|
||||||
|
- Update PostgreSQL password
|
||||||
|
|
||||||
|
2. **Enable HTTPS:**
|
||||||
|
- Uncomment HTTPS section in nginx.conf
|
||||||
|
- Add SSL certificates to `./ssl/` directory
|
||||||
|
- Update domain name in nginx.conf
|
||||||
|
|
||||||
|
3. **Database Security:**
|
||||||
|
- Use external PostgreSQL instance in production
|
||||||
|
- Implement proper backup strategy
|
||||||
|
- Use connection pooling
|
||||||
|
|
||||||
|
### Scaling
|
||||||
|
|
||||||
|
The application can be scaled horizontally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --scale tournament-app=3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Database Connection Issues
|
||||||
|
|
||||||
|
1. Check if PostgreSQL is running:
|
||||||
|
```bash
|
||||||
|
docker-compose ps postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check database logs:
|
||||||
|
```bash
|
||||||
|
docker-compose logs postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Application Issues
|
||||||
|
|
||||||
|
1. Check application logs:
|
||||||
|
```bash
|
||||||
|
docker-compose logs tournament-app
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Access application shell:
|
||||||
|
```bash
|
||||||
|
docker-compose exec tournament-app sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx Issues
|
||||||
|
|
||||||
|
1. Check nginx logs:
|
||||||
|
```bash
|
||||||
|
docker-compose logs nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Test nginx configuration:
|
||||||
|
```bash
|
||||||
|
docker-compose exec nginx nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Persistence
|
||||||
|
|
||||||
|
The PostgreSQL data is persisted in a Docker volume named `postgres_data`. To backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose exec postgres pg_dump -U tournament_user tournament > backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
To restore:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose exec -T postgres psql -U tournament_user tournament < backup.sql
|
||||||
|
```
|
||||||
36
docker/build.sh
Executable file
36
docker/build.sh
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Tournament Application Docker Build Script
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🏗️ Building Tournament Application Docker Image..."
|
||||||
|
|
||||||
|
# Check if we're in the docker directory
|
||||||
|
if [ ! -f "docker-compose.yml" ]; then
|
||||||
|
echo "❌ Error: Please run this script from the docker directory"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build the application image
|
||||||
|
echo "📦 Building application image..."
|
||||||
|
docker build -t tournament-app -f Dockerfile ..
|
||||||
|
|
||||||
|
# Check if build was successful
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✅ Application image built successfully!"
|
||||||
|
else
|
||||||
|
echo "❌ Failed to build application image"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🎉 Build completed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "To run the application:"
|
||||||
|
echo " docker-compose up -d"
|
||||||
|
echo ""
|
||||||
|
echo "To view logs:"
|
||||||
|
echo " docker-compose logs -f"
|
||||||
|
echo ""
|
||||||
|
echo "To stop the application:"
|
||||||
|
echo " docker-compose down"
|
||||||
55
docker/deploy.sh
Executable file
55
docker/deploy.sh
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Tournament Application Production Deployment Script
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 Deploying Tournament Application to Production..."
|
||||||
|
|
||||||
|
# Check if we're in the docker directory
|
||||||
|
if [ ! -f "docker-compose.prod.yml" ]; then
|
||||||
|
echo "❌ Error: Please run this script from the docker directory"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if .env file exists
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo "⚠️ Warning: No .env file found. Using default values."
|
||||||
|
echo " Create a .env file with production values for security."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop existing containers
|
||||||
|
echo "🛑 Stopping existing containers..."
|
||||||
|
docker-compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# Build and start the application
|
||||||
|
echo "🏗️ Building and starting application..."
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d --build
|
||||||
|
|
||||||
|
# Wait for services to be healthy
|
||||||
|
echo "⏳ Waiting for services to be ready..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Check if services are running
|
||||||
|
echo "🔍 Checking service status..."
|
||||||
|
docker-compose -f docker-compose.prod.yml ps
|
||||||
|
|
||||||
|
# Check application health
|
||||||
|
echo "🏥 Checking application health..."
|
||||||
|
if curl -f http://localhost/health > /dev/null 2>&1; then
|
||||||
|
echo "✅ Application is healthy!"
|
||||||
|
else
|
||||||
|
echo "⚠️ Application health check failed, but it might still be starting up..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🎉 Deployment completed!"
|
||||||
|
echo ""
|
||||||
|
echo "Application URLs:"
|
||||||
|
echo " Frontend: http://localhost"
|
||||||
|
echo " API: http://localhost:4000"
|
||||||
|
echo ""
|
||||||
|
echo "Useful commands:"
|
||||||
|
echo " View logs: docker-compose -f docker-compose.prod.yml logs -f"
|
||||||
|
echo " Stop app: docker-compose -f docker-compose.prod.yml down"
|
||||||
|
echo " Restart: docker-compose -f docker-compose.prod.yml restart"
|
||||||
95
docker/docker-compose.prod.yml
Normal file
95
docker/docker-compose.prod.yml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: tournament-postgres-prod
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: tournament
|
||||||
|
POSTGRES_USER: tournament_user
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me_in_production}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ../prisma/migrations:/docker-entrypoint-initdb.d
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432" # Only accessible from localhost
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U tournament_user -d tournament"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- tournament-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Tournament Application
|
||||||
|
tournament-app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
container_name: tournament-app-prod
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://tournament_user:${POSTGRES_PASSWORD:-change_me_in_production}@postgres:5432/tournament
|
||||||
|
- PORT=4000
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-change_me_in_production}
|
||||||
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-change_me_in_production}
|
||||||
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@tournament.com}
|
||||||
|
- NODE_ENV=production
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:4000:4000" # Only accessible from localhost
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ../prisma:/app/prisma
|
||||||
|
networks:
|
||||||
|
- tournament-network
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
cpus: '0.5'
|
||||||
|
reservations:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.25'
|
||||||
|
|
||||||
|
# Nginx Reverse Proxy
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: tournament-nginx-prod
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./ssl:/etc/nginx/ssl:ro
|
||||||
|
- nginx_logs:/var/log/nginx
|
||||||
|
depends_on:
|
||||||
|
- tournament-app
|
||||||
|
networks:
|
||||||
|
- tournament-network
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128M
|
||||||
|
cpus: '0.25'
|
||||||
|
reservations:
|
||||||
|
memory: 64M
|
||||||
|
cpus: '0.1'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
nginx_logs:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
tournament-network:
|
||||||
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.20.0.0/16
|
||||||
70
docker/docker-compose.yml
Normal file
70
docker/docker-compose.yml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# PostgreSQL Database
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: tournament-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: tournament
|
||||||
|
POSTGRES_USER: tournament_user
|
||||||
|
POSTGRES_PASSWORD: tournament_password
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./prisma/migrations:/docker-entrypoint-initdb.d
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U tournament_user -d tournament"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- tournament-network
|
||||||
|
|
||||||
|
# Tournament Application
|
||||||
|
tournament-app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
container_name: tournament-app
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://tournament_user:tournament_password@postgres:5432/tournament
|
||||||
|
- PORT=4000
|
||||||
|
- JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
- ADMIN_USERNAME=admin
|
||||||
|
- ADMIN_PASSWORD=admin123
|
||||||
|
- ADMIN_EMAIL=admin@tournament.com
|
||||||
|
ports:
|
||||||
|
- "4000:4000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ../prisma:/app/prisma
|
||||||
|
networks:
|
||||||
|
- tournament-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Nginx Reverse Proxy (Optional)
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: tournament-nginx
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./ssl:/etc/nginx/ssl:ro
|
||||||
|
depends_on:
|
||||||
|
- tournament-app
|
||||||
|
networks:
|
||||||
|
- tournament-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
tournament-network:
|
||||||
|
driver: bridge
|
||||||
85
docker/nginx.conf
Normal file
85
docker/nginx.conf
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
upstream tournament_backend {
|
||||||
|
server tournament-app:4000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
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;
|
||||||
|
|
||||||
|
# API routes
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://tournament_backend;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# WebSocket support for Socket.IO
|
||||||
|
location /socket.io/ {
|
||||||
|
proxy_pass http://tournament_backend;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serve static files (React build)
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
access_log off;
|
||||||
|
return 200 "healthy\n";
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS configuration (uncomment and configure SSL certificates)
|
||||||
|
# server {
|
||||||
|
# listen 443 ssl http2;
|
||||||
|
# server_name localhost;
|
||||||
|
#
|
||||||
|
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||||
|
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||||
|
#
|
||||||
|
# # SSL configuration
|
||||||
|
# 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;
|
||||||
|
#
|
||||||
|
# # Same location blocks as above
|
||||||
|
# location /api/ {
|
||||||
|
# proxy_pass http://tournament_backend;
|
||||||
|
# # ... same proxy settings
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
}
|
||||||
2000
package-lock.json
generated
Normal file
2000
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "julien",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.12.0",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.0",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"jspdf-autotable": "^5.0.2",
|
||||||
|
"multer": "^2.0.1",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"prisma": "^6.12.0",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"web-vitals": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"node-fetch": "^2.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
prisma/migrations/20250716180406_init/migration.sql
Normal file
77
prisma/migrations/20250716180406_init/migration.sql
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "StageType" AS ENUM ('ROUND_ROBIN', 'SINGLE_ELIM');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Player" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"registeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Player_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AdminUser" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TournamentStage" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"type" "StageType" NOT NULL,
|
||||||
|
"startedAt" TIMESTAMP(3),
|
||||||
|
"endedAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "TournamentStage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Match" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"stageId" INTEGER NOT NULL,
|
||||||
|
"player1Id" INTEGER NOT NULL,
|
||||||
|
"player2Id" INTEGER NOT NULL,
|
||||||
|
"scheduledAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Match_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Result" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"matchId" INTEGER NOT NULL,
|
||||||
|
"player1Score" INTEGER NOT NULL,
|
||||||
|
"player2Score" INTEGER NOT NULL,
|
||||||
|
"winnerId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Result_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Player_email_key" ON "Player"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AdminUser_username_key" ON "AdminUser"("username");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Result_matchId_key" ON "Result"("matchId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Match" ADD CONSTRAINT "Match_stageId_fkey" FOREIGN KEY ("stageId") REFERENCES "TournamentStage"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Match" ADD CONSTRAINT "Match_player1Id_fkey" FOREIGN KEY ("player1Id") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Match" ADD CONSTRAINT "Match_player2Id_fkey" FOREIGN KEY ("player2Id") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Result" ADD CONSTRAINT "Result_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "Match"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Result" ADD CONSTRAINT "Result_winnerId_fkey" FOREIGN KEY ("winnerId") REFERENCES "Player"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Player" ADD COLUMN "passwordHash" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Player" ADD COLUMN "imageUrl" TEXT;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[username]` on the table `Player` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Player" ADD COLUMN "username" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Player_username_key" ON "Player"("username");
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `email` on the `Player` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Player_email_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Player" DROP COLUMN "email";
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `player1Id` on the `Match` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `player2Id` on the `Match` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `player1Score` on the `Result` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `player2Score` on the `Result` table. All the data in the column will be lost.
|
||||||
|
- Added the required column `team1Id` to the `Match` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `team2Id` to the `Match` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `team1Score` to the `Result` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `team2Score` to the `Result` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Match" DROP CONSTRAINT "Match_player1Id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Match" DROP CONSTRAINT "Match_player2Id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Result" DROP CONSTRAINT "Result_winnerId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Match" DROP COLUMN "player1Id",
|
||||||
|
DROP COLUMN "player2Id",
|
||||||
|
ADD COLUMN "team1Id" INTEGER NOT NULL,
|
||||||
|
ADD COLUMN "team2Id" INTEGER NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Player" ADD COLUMN "teamId" INTEGER;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Result" DROP COLUMN "player1Score",
|
||||||
|
DROP COLUMN "player2Score",
|
||||||
|
ADD COLUMN "team1Score" INTEGER NOT NULL,
|
||||||
|
ADD COLUMN "team2Score" INTEGER NOT NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Team" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"registeredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Team_name_key" ON "Team"("name");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Player" ADD CONSTRAINT "Player_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Match" ADD CONSTRAINT "Match_team1Id_fkey" FOREIGN KEY ("team1Id") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Match" ADD CONSTRAINT "Match_team2Id_fkey" FOREIGN KEY ("team2Id") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Result" ADD CONSTRAINT "Result_winnerId_fkey" FOREIGN KEY ("winnerId") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `pool` to the `Match` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Match" ADD COLUMN "pool" INTEGER NOT NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TournamentStage" ADD COLUMN "tier" INTEGER;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `tournamentId` to the `TournamentStage` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TournamentStage" ADD COLUMN "tournamentId" INTEGER NOT NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Tournament" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"location" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Tournament_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TournamentStage" ADD CONSTRAINT "TournamentStage_tournamentId_fkey" FOREIGN KEY ("tournamentId") REFERENCES "Tournament"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Team" ADD COLUMN "logo" TEXT;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
92
prisma/schema.prisma
Normal file
92
prisma/schema.prisma
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||||
|
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
output = "../generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Team {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique
|
||||||
|
logo String? // Team logo URL or emoji
|
||||||
|
registeredAt DateTime @default(now())
|
||||||
|
players Player[]
|
||||||
|
matches1 Match[] @relation("Team1Matches")
|
||||||
|
matches2 Match[] @relation("Team2Matches")
|
||||||
|
resultsWon Result[] @relation("WinnerTeamResults")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Player {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
username String? @unique
|
||||||
|
registeredAt DateTime @default(now())
|
||||||
|
passwordHash String?
|
||||||
|
imageUrl String? // Optional profile image path
|
||||||
|
team Team? @relation(fields: [teamId], references: [id])
|
||||||
|
teamId Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
model AdminUser {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
username String @unique
|
||||||
|
passwordHash String
|
||||||
|
}
|
||||||
|
|
||||||
|
model Tournament {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
date DateTime
|
||||||
|
location String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
stages TournamentStage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model TournamentStage {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
type StageType
|
||||||
|
startedAt DateTime?
|
||||||
|
endedAt DateTime?
|
||||||
|
matches Match[]
|
||||||
|
tier Int? // Added for tiered elimination
|
||||||
|
tournament Tournament @relation(fields: [tournamentId], references: [id])
|
||||||
|
tournamentId Int
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StageType {
|
||||||
|
ROUND_ROBIN
|
||||||
|
SINGLE_ELIM
|
||||||
|
}
|
||||||
|
|
||||||
|
model Match {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
stage TournamentStage @relation(fields: [stageId], references: [id])
|
||||||
|
stageId Int
|
||||||
|
team1 Team @relation("Team1Matches", fields: [team1Id], references: [id])
|
||||||
|
team1Id Int
|
||||||
|
team2 Team @relation("Team2Matches", fields: [team2Id], references: [id])
|
||||||
|
team2Id Int
|
||||||
|
scheduledAt DateTime
|
||||||
|
result Result?
|
||||||
|
pool Int
|
||||||
|
}
|
||||||
|
|
||||||
|
model Result {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
match Match @relation(fields: [matchId], references: [id])
|
||||||
|
matchId Int @unique
|
||||||
|
team1Score Int
|
||||||
|
team2Score Int
|
||||||
|
winner Team @relation("WinnerTeamResults", fields: [winnerId], references: [id])
|
||||||
|
winnerId Int
|
||||||
|
}
|
||||||
86
scripts/addTeams.js
Normal file
86
scripts/addTeams.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
|
||||||
|
// Get number of teams from command line argument, default to 10
|
||||||
|
const numTeams = parseInt(process.argv[2]) || 10;
|
||||||
|
|
||||||
|
// Generate team names dynamically
|
||||||
|
const teamNames = [
|
||||||
|
'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta', 'Eta', 'Theta', 'Iota', 'Kappa',
|
||||||
|
'Lambda', 'Mu', 'Nu', 'Xi', 'Omicron', 'Pi', 'Rho', 'Sigma', 'Tau', 'Upsilon',
|
||||||
|
'Phi', 'Chi', 'Psi', 'Omega'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate SVG logo based on team name
|
||||||
|
function generateSVGLogo(teamName, size = 40) {
|
||||||
|
// Use team name to generate consistent colors and patterns
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < teamName.length; i++) {
|
||||||
|
hash = teamName.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hue = hash % 360;
|
||||||
|
const saturation = 60 + (hash % 30);
|
||||||
|
const lightness = 40 + (hash % 30);
|
||||||
|
const color = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||||
|
|
||||||
|
// Generate secondary color
|
||||||
|
const secondaryHue = (hue + 180) % 360;
|
||||||
|
const secondaryColor = `hsl(${secondaryHue}, ${saturation}%, ${lightness}%)`;
|
||||||
|
|
||||||
|
// Choose a logo pattern based on hash
|
||||||
|
const pattern = hash % 4;
|
||||||
|
const initials = teamName.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||||
|
|
||||||
|
let svgContent = '';
|
||||||
|
|
||||||
|
switch (pattern) {
|
||||||
|
case 0: // Circle with initials
|
||||||
|
svgContent = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><circle cx="${size/2}" cy="${size/2}" r="${size/2}" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><text x="50%" y="55%" text-anchor="middle" font-size="${size/3}" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 1: // Square with diagonal
|
||||||
|
svgContent = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect x="2" y="2" width="${size-4}" height="${size-4}" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><line x1="0" y1="0" x2="${size}" y2="${size}" stroke="${secondaryColor}" stroke-width="3"/><text x="50%" y="55%" text-anchor="middle" font-size="${size/3}" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2: // Triangle
|
||||||
|
svgContent = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><polygon points="${size/2},5 ${size-5},${size-5} 5,${size-5}" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><text x="50%" y="60%" text-anchor="middle" font-size="${size/3}" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3: // Diamond
|
||||||
|
svgContent = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><polygon points="${size/2},5 ${size-5},${size/2} ${size/2},${size-5} 5,${size/2}" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><text x="50%" y="55%" text-anchor="middle" font-size="${size/3}" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return svgContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teams = [];
|
||||||
|
for (let i = 0; i < numTeams; i++) {
|
||||||
|
const teamName = `Team ${teamNames[i]}`;
|
||||||
|
teams.push({
|
||||||
|
name: teamName,
|
||||||
|
logo: generateSVGLogo(teamName)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTeams() {
|
||||||
|
for (const team of teams) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:4000/api/teams', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: team.name, logo: team.logo })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
console.log(`Added: ${team.name} with SVG logo`);
|
||||||
|
} else {
|
||||||
|
console.log(`Failed to add ${team.name}: ${data.error}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error adding ${team.name}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addTeams();
|
||||||
16
scripts/checkAdmin.js
Normal file
16
scripts/checkAdmin.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const { PrismaClient } = require('../generated/prisma');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function checkAdmin() {
|
||||||
|
try {
|
||||||
|
const admins = await prisma.adminUser.findMany();
|
||||||
|
console.log('Admin users in database:', admins);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAdmin();
|
||||||
13
scripts/clearMatches.js
Normal file
13
scripts/clearMatches.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const { PrismaClient } = require('../generated/prisma');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
// Delete all results first (if results depend on matches)
|
||||||
|
const deletedResults = await prisma.result.deleteMany();
|
||||||
|
// Delete all matches
|
||||||
|
const deletedMatches = await prisma.match.deleteMany();
|
||||||
|
console.log(`Deleted ${deletedResults.count} results and ${deletedMatches.count} matches.`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
14
scripts/clearPlayers.js
Normal file
14
scripts/clearPlayers.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const { PrismaClient } = require('../generated/prisma');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const deletedResults = await prisma.result.deleteMany();
|
||||||
|
const deletedMatches = await prisma.match.deleteMany();
|
||||||
|
const deletedStages = await prisma.tournamentStage.deleteMany();
|
||||||
|
const deletedTeams = await prisma.team.deleteMany();
|
||||||
|
const deletedPlayers = await prisma.player.deleteMany();
|
||||||
|
console.log(`Deleted ${deletedResults.count} results, ${deletedMatches.count} matches, ${deletedStages.count} tournament stages, ${deletedTeams.count} teams, and ${deletedPlayers.count} players.`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
31
scripts/clearStages.js
Normal file
31
scripts/clearStages.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const { PrismaClient } = require('../generated/prisma');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
// Find all single elimination stages
|
||||||
|
const singleElimStages = await prisma.tournamentStage.findMany({ where: { type: 'SINGLE_ELIM' } });
|
||||||
|
if (!singleElimStages.length) {
|
||||||
|
console.log('No single elimination stages found.');
|
||||||
|
await prisma.$disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let totalDeletedMatches = 0;
|
||||||
|
let totalDeletedResults = 0;
|
||||||
|
for (const stage of singleElimStages) {
|
||||||
|
// Find all matches for this stage
|
||||||
|
const matches = await prisma.match.findMany({ where: { stageId: stage.id } });
|
||||||
|
const matchIds = matches.map(m => m.id);
|
||||||
|
// Delete all results for these matches
|
||||||
|
const deletedResults = await prisma.result.deleteMany({ where: { matchId: { in: matchIds } } });
|
||||||
|
// Delete all matches for this stage
|
||||||
|
const deletedMatches = await prisma.match.deleteMany({ where: { stageId: stage.id } });
|
||||||
|
// Delete the stage itself
|
||||||
|
await prisma.tournamentStage.delete({ where: { id: stage.id } });
|
||||||
|
totalDeletedMatches += deletedMatches.count;
|
||||||
|
totalDeletedResults += deletedResults.count;
|
||||||
|
}
|
||||||
|
console.log(`Deleted ${singleElimStages.length} SINGLE_ELIM stages, ${totalDeletedMatches} matches, and ${totalDeletedResults} results.`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
28
scripts/createAdmin.js
Normal file
28
scripts/createAdmin.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
const { PrismaClient } = require('../generated/prisma');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function createAdmin() {
|
||||||
|
try {
|
||||||
|
const username = 'admin';
|
||||||
|
const password = 'admin123';
|
||||||
|
const hash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
const admin = await prisma.adminUser.create({
|
||||||
|
data: { username, passwordHash: hash },
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Admin created successfully:', { id: admin.id, username: admin.username });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'P2002') {
|
||||||
|
console.log('Admin already exists');
|
||||||
|
} else {
|
||||||
|
console.error('Error creating admin:', err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createAdmin();
|
||||||
17
scripts/deleteAdmins.js
Normal file
17
scripts/deleteAdmins.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const { PrismaClient } = require('../generated/prisma');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function deleteAdmins() {
|
||||||
|
try {
|
||||||
|
console.log('Deleting all admin users...');
|
||||||
|
const result = await prisma.adminUser.deleteMany();
|
||||||
|
console.log(`Deleted ${result.count} admin users`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAdmins();
|
||||||
12
scripts/fullTournamentReset.js
Normal file
12
scripts/fullTournamentReset.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const { PrismaClient } = require('../generated/prisma');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const deletedResults = await prisma.result.deleteMany();
|
||||||
|
const deletedMatches = await prisma.match.deleteMany();
|
||||||
|
const deletedStages = await prisma.tournamentStage.deleteMany();
|
||||||
|
console.log(`Deleted ${deletedResults.count} results, ${deletedMatches.count} matches, and ${deletedStages.count} tournament stages.`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
73
scripts/generateTeamLogos.js
Normal file
73
scripts/generateTeamLogos.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const { PrismaClient } = require('../generated/prisma');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Generate random SVG logo based on team name
|
||||||
|
function generateSVGLogo(teamName, size = 40) {
|
||||||
|
// Use team name to generate consistent colors and patterns
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < teamName.length; i++) {
|
||||||
|
hash = teamName.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hue = hash % 360;
|
||||||
|
const saturation = 60 + (hash % 30);
|
||||||
|
const lightness = 40 + (hash % 30);
|
||||||
|
const color = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||||
|
|
||||||
|
// Generate secondary color
|
||||||
|
const secondaryHue = (hue + 180) % 360;
|
||||||
|
const secondaryColor = `hsl(${secondaryHue}, ${saturation}%, ${lightness}%)`;
|
||||||
|
|
||||||
|
// Choose a logo pattern based on hash
|
||||||
|
const pattern = hash % 4;
|
||||||
|
const initials = teamName.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||||
|
|
||||||
|
let svgContent = '';
|
||||||
|
|
||||||
|
switch (pattern) {
|
||||||
|
case 0: // Circle with initials
|
||||||
|
svgContent = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><circle cx="${size/2}" cy="${size/2}" r="${size/2}" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><text x="50%" y="55%" text-anchor="middle" font-size="${size/3}" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 1: // Square with diagonal
|
||||||
|
svgContent = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect x="2" y="2" width="${size-4}" height="${size-4}" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><line x1="0" y1="0" x2="${size}" y2="${size}" stroke="${secondaryColor}" stroke-width="3"/><text x="50%" y="55%" text-anchor="middle" font-size="${size/3}" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 2: // Triangle
|
||||||
|
svgContent = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><polygon points="${size/2},5 ${size-5},${size-5} 5,${size-5}" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><text x="50%" y="60%" text-anchor="middle" font-size="${size/3}" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 3: // Diamond
|
||||||
|
svgContent = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><polygon points="${size/2},5 ${size-5},${size/2} ${size/2},${size-5} 5,${size/2}" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><text x="50%" y="55%" text-anchor="middle" font-size="${size/3}" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return svgContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTeamLogos() {
|
||||||
|
try {
|
||||||
|
const teams = await prisma.team.findMany();
|
||||||
|
console.log(`Found ${teams.length} teams to update`);
|
||||||
|
|
||||||
|
for (const team of teams) {
|
||||||
|
const svgLogo = generateSVGLogo(team.name);
|
||||||
|
|
||||||
|
await prisma.team.update({
|
||||||
|
where: { id: team.id },
|
||||||
|
data: { logo: svgLogo }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Updated ${team.name} with SVG logo`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ All team logos updated successfully!');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating team logos:', err);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTeamLogos();
|
||||||
101
scripts/resetTournament.js
Normal file
101
scripts/resetTournament.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
const { PrismaClient } = require('../generated/prisma');
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// Generate SVG logo based on team name
|
||||||
|
function generateSVGLogo(teamName, size = 40) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < teamName.length; i++) {
|
||||||
|
hash = teamName.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hue = Math.abs(hash) % 360;
|
||||||
|
const saturation = 60 + (Math.abs(hash) % 30);
|
||||||
|
const lightness = 40 + (Math.abs(hash) % 30);
|
||||||
|
const color = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||||
|
|
||||||
|
const secondaryHue = (hue + 180) % 360;
|
||||||
|
const secondaryColor = `hsl(${secondaryHue}, ${saturation}%, ${lightness}%)`;
|
||||||
|
|
||||||
|
const pattern = Math.abs(hash) % 4;
|
||||||
|
const initials = teamName.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||||
|
|
||||||
|
let svgContent = '';
|
||||||
|
|
||||||
|
switch (pattern) {
|
||||||
|
case 0: // Circle with initials
|
||||||
|
svgContent = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><circle cx="${size/2}" cy="${size/2}" r="${size/2}" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><text x="50%" y="55%" text-anchor="middle" font-size="${size/3}" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
||||||
|
break;
|
||||||
|
case 1: // Square with diagonal
|
||||||
|
svgContent = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect x="2" y="2" width="${size-4}" height="${size-4}" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><line x1="0" y1="0" x2="${size}" y2="${size}" stroke="${secondaryColor}" stroke-width="3"/><text x="50%" y="55%" text-anchor="middle" font-size="${size/3}" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
||||||
|
break;
|
||||||
|
case 2: // Triangle
|
||||||
|
svgContent = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><polygon points="${size/2},5 ${size-5},${size-5} 5,${size-5}" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><text x="50%" y="60%" text-anchor="middle" font-size="${size/3}" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
||||||
|
break;
|
||||||
|
case 3: // Diamond
|
||||||
|
svgContent = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><polygon points="${size/2},5 ${size-5},${size/2} ${size/2},${size-5} 5,${size/2}" fill="${color}" stroke="${secondaryColor}" stroke-width="2"/><text x="50%" y="55%" text-anchor="middle" font-size="${size/3}" fill="white" font-family="Arial, sans-serif" font-weight="bold">${initials}</text></svg>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return svgContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetTournament() {
|
||||||
|
try {
|
||||||
|
console.log('🗑️ Deleting all existing data...');
|
||||||
|
|
||||||
|
// Delete all data in the correct order (due to foreign key constraints)
|
||||||
|
await prisma.result.deleteMany();
|
||||||
|
await prisma.match.deleteMany();
|
||||||
|
await prisma.tournamentStage.deleteMany();
|
||||||
|
await prisma.tournament.deleteMany();
|
||||||
|
await prisma.team.deleteMany();
|
||||||
|
|
||||||
|
console.log('✅ All existing data deleted');
|
||||||
|
|
||||||
|
// Create new tournament
|
||||||
|
console.log('🏆 Creating new tournament...');
|
||||||
|
const tournament = await prisma.tournament.create({
|
||||||
|
data: {
|
||||||
|
name: 'Championship Tournament',
|
||||||
|
date: new Date(),
|
||||||
|
location: 'Main Arena'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`✅ Tournament created: ${tournament.name}`);
|
||||||
|
|
||||||
|
// Create 15 teams
|
||||||
|
console.log('👥 Creating 15 teams...');
|
||||||
|
const teamNames = [
|
||||||
|
'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta', 'Eta', 'Theta', 'Iota', 'Kappa',
|
||||||
|
'Lambda', 'Mu', 'Nu', 'Xi', 'Omicron'
|
||||||
|
];
|
||||||
|
|
||||||
|
const teams = [];
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
const teamName = `Team ${teamNames[i]}`;
|
||||||
|
const team = await prisma.team.create({
|
||||||
|
data: {
|
||||||
|
name: teamName,
|
||||||
|
logo: generateSVGLogo(teamName)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
teams.push(team);
|
||||||
|
console.log(`✅ Created ${team.name} with SVG logo`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n🎉 Tournament reset complete!`);
|
||||||
|
console.log(`📊 Tournament: ${tournament.name}`);
|
||||||
|
console.log(`👥 Teams: ${teams.length}`);
|
||||||
|
console.log(`📅 Date: ${tournament.date.toLocaleDateString()}`);
|
||||||
|
console.log(`📍 Location: ${tournament.location}`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error resetting tournament:', err);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTournament();
|
||||||
60
scripts/scheduleAndFillResults.js
Normal file
60
scripts/scheduleAndFillResults.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
async function prompt(question) {
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans); }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomScore() {
|
||||||
|
// No draws allowed, so scores must be different
|
||||||
|
let a = Math.floor(Math.random() * 10);
|
||||||
|
let b = Math.floor(Math.random() * 10);
|
||||||
|
while (a === b) {
|
||||||
|
b = Math.floor(Math.random() * 10);
|
||||||
|
}
|
||||||
|
return [a, b];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const username = process.env.ADMIN_USERNAME || await prompt('Admin username: ');
|
||||||
|
const password = process.env.ADMIN_PASSWORD || await prompt('Admin password: ');
|
||||||
|
|
||||||
|
// Login as admin
|
||||||
|
const loginRes = await fetch('http://localhost:4000/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
const loginData = await loginRes.json();
|
||||||
|
if (!loginRes.ok || !loginData.token) {
|
||||||
|
console.error('Admin login failed:', loginData.error || loginRes.statusText);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const token = loginData.token;
|
||||||
|
console.log('Admin login successful. Filling in match results...');
|
||||||
|
|
||||||
|
// Fetch all matches
|
||||||
|
const matchesRes = await fetch('http://localhost:4000/api/matches');
|
||||||
|
const matches = await matchesRes.json();
|
||||||
|
for (const match of matches) {
|
||||||
|
if (match.result) continue; // Skip if result already exists
|
||||||
|
const [team1Score, team2Score] = getRandomScore();
|
||||||
|
const resultRes = await fetch(`http://localhost:4000/api/admin/matches/${match.id}/result`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ team1Score, team2Score })
|
||||||
|
});
|
||||||
|
if (resultRes.ok) {
|
||||||
|
console.log(`Filled result for match ${match.id}: ${team1Score} - ${team2Score}`);
|
||||||
|
} else {
|
||||||
|
const err = await resultRes.json();
|
||||||
|
console.log(`Failed to fill result for match ${match.id}:`, err.error || resultRes.statusText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
49
scripts/scheduleRoundRobin.js
Normal file
49
scripts/scheduleRoundRobin.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
async function prompt(question) {
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans); }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const username = process.env.ADMIN_USERNAME || await prompt('Admin username: ');
|
||||||
|
const password = process.env.ADMIN_PASSWORD || await prompt('Admin password: ');
|
||||||
|
let minTeamsPerPool = 3;
|
||||||
|
const minTeamsInput = await prompt('Minimum teams per pool (default 3): ');
|
||||||
|
if (minTeamsInput && !isNaN(parseInt(minTeamsInput))) {
|
||||||
|
minTeamsPerPool = parseInt(minTeamsInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login as admin
|
||||||
|
const loginRes = await fetch('http://localhost:4000/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
const loginData = await loginRes.json();
|
||||||
|
if (!loginRes.ok || !loginData.token) {
|
||||||
|
console.error('Admin login failed:', loginData.error || loginRes.statusText);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const token = loginData.token;
|
||||||
|
console.log('Admin login successful. Scheduling round robin matches...');
|
||||||
|
|
||||||
|
// Call schedule round robin endpoint
|
||||||
|
const scheduleRes = await fetch('http://localhost:4000/api/admin/schedule/roundrobin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ minTeamsPerPool })
|
||||||
|
});
|
||||||
|
const scheduleData = await scheduleRes.json();
|
||||||
|
if (scheduleRes.ok) {
|
||||||
|
console.log('Round robin scheduled:', scheduleData);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to schedule round robin:', scheduleData.error || scheduleRes.statusText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
40
scripts/scheduleSingleElim.js
Normal file
40
scripts/scheduleSingleElim.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
async function prompt(question) {
|
||||||
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans); }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const username = process.env.ADMIN_USERNAME || await prompt('Admin username: ');
|
||||||
|
const password = process.env.ADMIN_PASSWORD || await prompt('Admin password: ');
|
||||||
|
|
||||||
|
// Login as admin
|
||||||
|
const loginRes = await fetch('http://localhost:4000/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
const loginData = await loginRes.json();
|
||||||
|
if (!loginRes.ok || !loginData.token) {
|
||||||
|
console.error('Admin login failed:', loginData.error || loginRes.statusText);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const token = loginData.token;
|
||||||
|
console.log('Admin login successful. Scheduling single elimination bracket...');
|
||||||
|
|
||||||
|
// Call schedule single elimination endpoint
|
||||||
|
const scheduleRes = await fetch('http://localhost:4000/api/admin/schedule/singleelim', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const scheduleData = await scheduleRes.json();
|
||||||
|
if (scheduleRes.ok) {
|
||||||
|
console.log('Single elimination scheduled:', scheduleData);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to schedule single elimination:', scheduleData.error || scheduleRes.statusText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
24
scripts/setPlayerUsernames.js
Normal file
24
scripts/setPlayerUsernames.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const { PrismaClient } = require('../generated/prisma');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const players = await prisma.player.findMany();
|
||||||
|
const usedUsernames = new Set();
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
let base = player.email.split('@')[0];
|
||||||
|
let username = base;
|
||||||
|
let i = 1;
|
||||||
|
// Ensure uniqueness
|
||||||
|
while (usedUsernames.has(username) || await prisma.player.findUnique({ where: { username } })) {
|
||||||
|
username = `${base}${i}`;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
usedUsernames.add(username);
|
||||||
|
await prisma.player.update({ where: { id: player.id }, data: { username } });
|
||||||
|
console.log(`Set username for ${player.email}: ${username}`);
|
||||||
|
}
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1); });
|
||||||
57
scripts/startTournament.js
Normal file
57
scripts/startTournament.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
|
||||||
|
async function startTournament() {
|
||||||
|
try {
|
||||||
|
// 1. Login as admin with .env credentials
|
||||||
|
console.log('Logging in as admin...');
|
||||||
|
const loginRes = await fetch('http://localhost:4000/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: 'admin',
|
||||||
|
password: '0000'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginRes.ok) {
|
||||||
|
const error = await loginRes.json();
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await loginRes.json();
|
||||||
|
console.log('✅ Login successful!');
|
||||||
|
|
||||||
|
// 2. Start round robin
|
||||||
|
console.log('Starting round robin...');
|
||||||
|
const scheduleRes = await fetch('http://localhost:4000/api/admin/schedule/roundrobin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (scheduleRes.ok) {
|
||||||
|
const data = await scheduleRes.json();
|
||||||
|
console.log('✅ Round robin started successfully!');
|
||||||
|
console.log('Pools created:', data.pools);
|
||||||
|
console.log('Matches created:', data.matchesCreated);
|
||||||
|
|
||||||
|
// 3. Check the created matches
|
||||||
|
console.log('Checking created matches...');
|
||||||
|
const matchesRes = await fetch('http://localhost:4000/api/matches');
|
||||||
|
if (matchesRes.ok) {
|
||||||
|
const matches = await matchesRes.json();
|
||||||
|
console.log(`✅ Found ${matches.length} matches created`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const error = await scheduleRes.json();
|
||||||
|
console.error('❌ Failed to start round robin:', error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startTournament();
|
||||||
44
scripts/testEnvLogin.js
Normal file
44
scripts/testEnvLogin.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
|
||||||
|
async function testEnvLogin() {
|
||||||
|
try {
|
||||||
|
console.log('Testing admin login with .env credentials...');
|
||||||
|
console.log('Username: admin, Password: 0000');
|
||||||
|
|
||||||
|
const loginRes = await fetch('http://localhost:4000/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: 'admin',
|
||||||
|
password: '0000'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Response status:', loginRes.status);
|
||||||
|
const data = await loginRes.json();
|
||||||
|
console.log('Response data:', data);
|
||||||
|
|
||||||
|
if (loginRes.ok) {
|
||||||
|
console.log('✅ Login successful! Token received');
|
||||||
|
|
||||||
|
// Test the token by calling a protected endpoint
|
||||||
|
console.log('Testing protected endpoint...');
|
||||||
|
const teamsRes = await fetch('http://localhost:4000/api/teams', {
|
||||||
|
headers: { 'Authorization': `Bearer ${data.token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (teamsRes.ok) {
|
||||||
|
const teams = await teamsRes.json();
|
||||||
|
console.log(`✅ Token works! Found ${teams.length} teams`);
|
||||||
|
} else {
|
||||||
|
console.log('❌ Token validation failed');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('❌ Login failed:', data.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testEnvLogin();
|
||||||
30
scripts/testLogin.js
Normal file
30
scripts/testLogin.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
async function testLogin() {
|
||||||
|
try {
|
||||||
|
console.log('Testing admin login...');
|
||||||
|
const loginRes = await fetch('http://localhost:4000/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: 'admin',
|
||||||
|
password: 'admin123'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Response status:', loginRes.status);
|
||||||
|
const data = await loginRes.json();
|
||||||
|
console.log('Response data:', data);
|
||||||
|
|
||||||
|
if (loginRes.ok) {
|
||||||
|
console.log('Login successful! Token:', data.token);
|
||||||
|
} else {
|
||||||
|
console.log('Login failed:', data.error);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testLogin();
|
||||||
34
scripts/verifyPassword.js
Normal file
34
scripts/verifyPassword.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const { PrismaClient } = require('../generated/prisma');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function verifyPassword() {
|
||||||
|
try {
|
||||||
|
const admin = await prisma.adminUser.findUnique({ where: { username: 'admin' } });
|
||||||
|
if (!admin) {
|
||||||
|
console.log('Admin not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Admin found:', { id: admin.id, username: admin.username });
|
||||||
|
console.log('Password hash:', admin.passwordHash);
|
||||||
|
|
||||||
|
const password = 'admin123';
|
||||||
|
const isValid = await bcrypt.compare(password, admin.passwordHash);
|
||||||
|
console.log('Password valid:', isValid);
|
||||||
|
|
||||||
|
// Test with a new hash
|
||||||
|
const newHash = await bcrypt.hash(password, 10);
|
||||||
|
console.log('New hash for same password:', newHash);
|
||||||
|
const isValidNew = await bcrypt.compare(password, newHash);
|
||||||
|
console.log('New hash valid:', isValidNew);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error:', err);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyPassword();
|
||||||
17659
tournament-frontend/package-lock.json
generated
Normal file
17659
tournament-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
tournament-frontend/package.json
Normal file
38
tournament-frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "tournament-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"cra-template-pwa": "2.0.0",
|
||||||
|
"jspdf": "^3.0.1",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"socket.io-client": "^4.8.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"proxy": "http://localhost:4000"
|
||||||
|
}
|
||||||
BIN
tournament-frontend/public/favicon.ico
Normal file
BIN
tournament-frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
41
tournament-frontend/public/index.html
Normal file
41
tournament-frontend/public/index.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta name="description" content="Web site created using create-react-app" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-eval'">
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>React Tournament App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
tournament-frontend/public/logo192.png
Normal file
BIN
tournament-frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
tournament-frontend/public/logo512.png
Normal file
BIN
tournament-frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
tournament-frontend/public/manifest.json
Normal file
25
tournament-frontend/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
3
tournament-frontend/public/robots.txt
Normal file
3
tournament-frontend/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
38
tournament-frontend/src/App.css
Normal file
38
tournament-frontend/src/App.css
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
.App {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-logo {
|
||||||
|
height: 40vmin;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.App-logo {
|
||||||
|
animation: App-logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-header {
|
||||||
|
background-color: #282c34;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: calc(10px + 2vmin);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-link {
|
||||||
|
color: #61dafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes App-logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
335
tournament-frontend/src/App.js
Normal file
335
tournament-frontend/src/App.js
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import './App.css';
|
||||||
|
import Header from './Header';
|
||||||
|
import UserMenu from './UserMenu';
|
||||||
|
import Login from './Login';
|
||||||
|
import ForgotPassword from './ForgotPassword';
|
||||||
|
import ResetPassword from './ResetPassword';
|
||||||
|
import Register from './Register';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
import MatchesSchedule from './MatchesSchedule';
|
||||||
|
import Results from './Results';
|
||||||
|
import Bracket from './Bracket';
|
||||||
|
import TeamRanking from './TeamRanking';
|
||||||
|
import Pools from './Pools';
|
||||||
|
import jsPDF from 'jspdf';
|
||||||
|
|
||||||
|
function getResetTokenFromUrl() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return params.get('token') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [token, setToken] = useState(null);
|
||||||
|
const [userMenu, setUserMenu] = useState(null);
|
||||||
|
const [showForgot, setShowForgot] = useState(false);
|
||||||
|
const [showRegister, setShowRegister] = useState(false);
|
||||||
|
const [section, setSection] = useState('leaderboard');
|
||||||
|
const [rankingRefresh, setRankingRefresh] = useState(0);
|
||||||
|
const [tournamentName, setTournamentName] = useState(() => localStorage.getItem('tournamentName') || '');
|
||||||
|
const [showCreateTournament, setShowCreateTournament] = useState(false);
|
||||||
|
const [showLoadTournaments, setShowLoadTournaments] = useState(false);
|
||||||
|
const [tournaments, setTournaments] = useState([]);
|
||||||
|
const [loadingTournaments, setLoadingTournaments] = useState(false);
|
||||||
|
const [tournamentLocation, setTournamentLocation] = useState(() => localStorage.getItem('tournamentLocation') || '');
|
||||||
|
const [tournamentDate, setTournamentDate] = useState(() => localStorage.getItem('tournamentDate') || '');
|
||||||
|
const [startMsg, setStartMsg] = useState('');
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [tournamentStarted, setTournamentStarted] = useState(false);
|
||||||
|
|
||||||
|
// Check if tournament has started (any round robin matches exist)
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch('/api/matches')
|
||||||
|
.then(res => res.ok ? res.json() : [])
|
||||||
|
.then(data => {
|
||||||
|
setTournamentStarted(data.some(m => m.stage?.type === 'ROUND_ROBIN'));
|
||||||
|
});
|
||||||
|
}, [startMsg]);
|
||||||
|
|
||||||
|
const handleUserMenuSelect = async (option) => {
|
||||||
|
if (option === 'logout') {
|
||||||
|
setToken(null);
|
||||||
|
setUserMenu(null);
|
||||||
|
} else if (option === 'create-tournament') {
|
||||||
|
setShowCreateTournament(true);
|
||||||
|
} else if (option === 'load-tournaments') {
|
||||||
|
setShowLoadTournaments(true);
|
||||||
|
setLoadingTournaments(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tournaments');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setTournaments(data);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to load tournaments');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading tournaments:', err);
|
||||||
|
}
|
||||||
|
setLoadingTournaments(false);
|
||||||
|
} else if (option === 'start-tournament') {
|
||||||
|
setStartMsg('');
|
||||||
|
setStarting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/schedule/roundrobin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setStartMsg('Tournament started!');
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setStartMsg(data.error || 'Failed to start tournament');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setStartMsg('Failed to start tournament');
|
||||||
|
}
|
||||||
|
setStarting(false);
|
||||||
|
} else {
|
||||||
|
setUserMenu(option);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-login after registration
|
||||||
|
const handleRegister = async (username, password) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.token) {
|
||||||
|
setToken(data.token);
|
||||||
|
setShowRegister(false);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler for resetting the tournament
|
||||||
|
const handleResetTournament = async () => {
|
||||||
|
// 1. Fetch all relevant data
|
||||||
|
const [teams, matches, standings] = await Promise.all([
|
||||||
|
fetch('/api/teams').then(res => res.ok ? res.json() : []),
|
||||||
|
fetch('/api/matches').then(res => res.ok ? res.json() : []),
|
||||||
|
fetch('/api/standings').then(res => res.ok ? res.json() : [])
|
||||||
|
]);
|
||||||
|
// 2. Generate PDF
|
||||||
|
const doc = new jsPDF();
|
||||||
|
doc.setFontSize(18);
|
||||||
|
const dateStr = new Date().toLocaleString();
|
||||||
|
doc.text(tournamentName ? `Tournament: ${tournamentName}` : 'Tournament Summary', 14, 18);
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.text(`Date: ${dateStr}`, 14, 26);
|
||||||
|
let y = 34;
|
||||||
|
doc.text('Teams:', 14, y);
|
||||||
|
y += 6;
|
||||||
|
teams.forEach(t => { doc.text(`- ${t.name}`, 18, y); y += 6; });
|
||||||
|
y += 4;
|
||||||
|
doc.text('Matches:', 14, y); y += 6;
|
||||||
|
matches.forEach(m => {
|
||||||
|
const t1 = m.team1?.name || 'TBD';
|
||||||
|
const t2 = m.team2?.name || 'TBD';
|
||||||
|
const score = m.result ? `${m.result.team1Score} - ${m.result.team2Score}` : '';
|
||||||
|
doc.text(`${t1} vs ${t2} ${score}`, 18, y); y += 6;
|
||||||
|
if (y > 270) { doc.addPage(); y = 18; }
|
||||||
|
});
|
||||||
|
y += 4;
|
||||||
|
doc.text('Final Rankings:', 14, y); y += 6;
|
||||||
|
standings.forEach((s, i) => { doc.text(`${i + 1}. ${s.name} (${s.points} pts)`, 18, y); y += 6; });
|
||||||
|
// 3. Download PDF
|
||||||
|
doc.save('tournament-summary.pdf');
|
||||||
|
// 4. Call backend to reset tournament
|
||||||
|
await fetch('/api/admin/reset-tournament', { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } });
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show reset password form if token is in URL
|
||||||
|
const resetToken = getResetTokenFromUrl();
|
||||||
|
if (resetToken) {
|
||||||
|
return <ResetPassword token={resetToken} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show login/register/forgot only for user menu actions
|
||||||
|
let userMenuContent = null;
|
||||||
|
if (!token && (userMenu === 'profile' || userMenu === 'edit' || userMenu === 'password')) {
|
||||||
|
if (showRegister) {
|
||||||
|
userMenuContent = <Register onRegister={handleRegister} onBackToLogin={() => setShowRegister(false)} />;
|
||||||
|
} else if (showForgot) {
|
||||||
|
userMenuContent = <ForgotPassword onResetRequested={() => setShowForgot(false)} />;
|
||||||
|
} else {
|
||||||
|
userMenuContent = <Login onLogin={setToken} onForgotPassword={() => setShowForgot(true)} onShowRegister={() => setShowRegister(true)} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tournament creation form modal
|
||||||
|
const createTournamentModal = showCreateTournament && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.25)', zIndex: 1000,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<form
|
||||||
|
onSubmit={async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tournaments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: tournamentName,
|
||||||
|
date: tournamentDate,
|
||||||
|
location: tournamentLocation
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
localStorage.setItem('tournamentName', tournamentName);
|
||||||
|
localStorage.setItem('tournamentDate', tournamentDate);
|
||||||
|
localStorage.setItem('tournamentLocation', tournamentLocation);
|
||||||
|
setShowCreateTournament(false);
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.error || 'Failed to create tournament');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to create tournament');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ background: '#fff', padding: 32, borderRadius: 12, minWidth: 320, boxShadow: '0 2px 16px rgba(0,0,0,0.12)' }}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginBottom: 16 }}>Create Tournament</h2>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<label style={{ display: 'block', fontWeight: 500 }}>Name</label>
|
||||||
|
<input type="text" value={tournamentName} onChange={e => { setTournamentName(e.target.value); }} required style={{ width: '100%', padding: 8, borderRadius: 6, border: '1px solid #ccc' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<label style={{ display: 'block', fontWeight: 500 }}>Date</label>
|
||||||
|
<input type="date" value={tournamentDate} onChange={e => { setTournamentDate(e.target.value); }} required style={{ width: '100%', padding: 8, borderRadius: 6, border: '1px solid #ccc' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<label style={{ display: 'block', fontWeight: 500 }}>Location</label>
|
||||||
|
<input type="text" value={tournamentLocation} onChange={e => { setTournamentLocation(e.target.value); }} required style={{ width: '100%', padding: 8, borderRadius: 6, border: '1px solid #ccc' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
|
||||||
|
<button type="button" onClick={() => setShowCreateTournament(false)} style={{ background: '#e5e7eb', color: '#222', border: 'none', borderRadius: 6, padding: '8px 20px', fontWeight: 'bold', cursor: 'pointer' }}>Cancel</button>
|
||||||
|
<button type="submit" style={{ background: '#2563eb', color: '#fff', border: 'none', borderRadius: 6, padding: '8px 20px', fontWeight: 'bold', cursor: 'pointer' }}>Create</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load tournaments modal
|
||||||
|
const loadTournamentsModal = showLoadTournaments && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.25)', zIndex: 1000,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<div style={{ background: '#fff', padding: 32, borderRadius: 12, minWidth: 500, maxHeight: '80vh', overflow: 'auto', boxShadow: '0 2px 16px rgba(0,0,0,0.12)' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<h2 style={{ margin: 0 }}>Available Tournaments</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLoadTournaments(false)}
|
||||||
|
style={{ background: 'none', border: 'none', fontSize: '24px', cursor: 'pointer', color: '#666' }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingTournaments ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<div>Loading tournaments...</div>
|
||||||
|
</div>
|
||||||
|
) : tournaments.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px', color: '#666' }}>
|
||||||
|
No tournaments available.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
{tournaments.map(tournament => (
|
||||||
|
<div
|
||||||
|
key={tournament.id}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
':hover': { borderColor: '#2563eb', backgroundColor: '#f8fafc' }
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
setTournamentName(tournament.name);
|
||||||
|
setTournamentDate(tournament.date);
|
||||||
|
setTournamentLocation(tournament.location);
|
||||||
|
localStorage.setItem('tournamentName', tournament.name);
|
||||||
|
localStorage.setItem('tournamentDate', tournament.date);
|
||||||
|
localStorage.setItem('tournamentLocation', tournament.location);
|
||||||
|
setShowLoadTournaments(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 8px 0', fontSize: '18px', fontWeight: '600' }}>
|
||||||
|
{tournament.name}
|
||||||
|
</h3>
|
||||||
|
<div style={{ color: '#666', fontSize: '14px' }}>
|
||||||
|
{tournament.date} • {tournament.location} • {tournament.teams} teams
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: 12,
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
backgroundColor:
|
||||||
|
tournament.status === 'active' ? '#dcfce7' :
|
||||||
|
tournament.status === 'completed' ? '#fef3c7' :
|
||||||
|
'#dbeafe',
|
||||||
|
color:
|
||||||
|
tournament.status === 'active' ? '#166534' :
|
||||||
|
tournament.status === 'completed' ? '#92400e' :
|
||||||
|
'#1e40af'
|
||||||
|
}}>
|
||||||
|
{tournament.status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header tournamentName={tournamentName} />
|
||||||
|
<div style={{ display: 'flex', marginTop: '60px', height: 'calc(100vh - 60px)' }}>
|
||||||
|
{createTournamentModal}
|
||||||
|
{loadTournamentsModal}
|
||||||
|
{startMsg && (
|
||||||
|
<div style={{ position: 'fixed', top: 80, right: 40, background: '#fff', color: startMsg.startsWith('Tournament started') ? '#22c55e' : '#b91c1c', fontWeight: 'bold', padding: '12px 24px', borderRadius: 8, boxShadow: '0 2px 8px rgba(0,0,0,0.08)', zIndex: 2000 }}>
|
||||||
|
{starting ? 'Starting...' : startMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Sidebar onSelect={setSection} selected={section} />
|
||||||
|
<div style={{ marginLeft: 220, flex: 1, overflow: 'auto' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '1rem' }}>
|
||||||
|
<UserMenu onSelect={handleUserMenuSelect} token={token} onResetTournament={handleResetTournament} tournamentStarted={tournamentStarted} />
|
||||||
|
</div>
|
||||||
|
{userMenuContent}
|
||||||
|
{section === 'schedule' && <MatchesSchedule />}
|
||||||
|
{section === 'results' && <Results />}
|
||||||
|
{section === 'bracket' && <Bracket token={token} onTierComplete={() => setRankingRefresh(r => r + 1)} />}
|
||||||
|
{section === 'pools' && <Pools token={token} onTournamentNameChange={setTournamentName} />}
|
||||||
|
{section === 'team-ranking' && <TeamRanking refresh={rankingRefresh} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
9
tournament-frontend/src/App.test.js
Normal file
9
tournament-frontend/src/App.test.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
test('renders learn react link', () => {
|
||||||
|
render(<App />);
|
||||||
|
const linkElement = screen.getByText(/learn react/i);
|
||||||
|
expect(linkElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
119
tournament-frontend/src/Bracket.css
Normal file
119
tournament-frontend/src/Bracket.css
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
.bracket-container {
|
||||||
|
padding: 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-flex {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-round {
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 900px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
flex: 1 0 260px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-round-title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-match {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-player {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-vs {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-winner {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #22c55e;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-edit-btn {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-edit-btn:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bracket-modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 32px rgba(0,0,0,0.18);
|
||||||
|
padding: 32px 24px 24px 24px;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 90vw;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.bracket-round {
|
||||||
|
min-width: 90vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.bracket-flex {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.bracket-modal {
|
||||||
|
min-width: 90vw;
|
||||||
|
padding: 18px 8px 12px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
261
tournament-frontend/src/Bracket.js
Normal file
261
tournament-frontend/src/Bracket.js
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import './Bracket.css';
|
||||||
|
import TeamLogo from './TeamLogo';
|
||||||
|
|
||||||
|
// Helper to group matches by round (assumes matches are ordered by round)
|
||||||
|
function groupMatchesByRound(matches) {
|
||||||
|
const rounds = {};
|
||||||
|
matches.forEach(match => {
|
||||||
|
const key = match.scheduledAt;
|
||||||
|
if (!rounds[key]) rounds[key] = [];
|
||||||
|
rounds[key].push(match);
|
||||||
|
});
|
||||||
|
return Object.values(rounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tierColors = [
|
||||||
|
'#f8fafc', // light blue
|
||||||
|
'#fef9c3', // light yellow
|
||||||
|
'#fce7f3', // light pink
|
||||||
|
'#d1fae5', // light green
|
||||||
|
'#fee2e2', // light red
|
||||||
|
'#e0e7ff', // light purple
|
||||||
|
];
|
||||||
|
|
||||||
|
const Bracket = ({ token, onTierComplete }) => {
|
||||||
|
const [tiers, setTiers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingMatch, setEditingMatch] = useState(null);
|
||||||
|
const [score1, setScore1] = useState('');
|
||||||
|
const [score2, setScore2] = useState('');
|
||||||
|
const [submitMsg, setSubmitMsg] = useState('');
|
||||||
|
|
||||||
|
const fetchBrackets = () => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch('/api/matches')
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject(res))
|
||||||
|
.then(data => {
|
||||||
|
const tierMap = {};
|
||||||
|
data.forEach(match => {
|
||||||
|
if (match.stage?.type === 'SINGLE_ELIM') {
|
||||||
|
const tier = match.stage.tier || 1;
|
||||||
|
if (!tierMap[tier]) tierMap[tier] = [];
|
||||||
|
tierMap[tier].push(match);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const tiersArr = Object.entries(tierMap)
|
||||||
|
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||||
|
.map(([tier, matches]) => ({ tier: Number(tier), matches }));
|
||||||
|
setTiers(tiersArr);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Failed to load brackets');
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to fetch brackets and, if all results are set for the latest round, advance the tier
|
||||||
|
const fetchBracketsAndAdvance = async (tier) => {
|
||||||
|
fetch('/api/matches')
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject(res))
|
||||||
|
.then(async data => {
|
||||||
|
const tierMatches = data.filter(match => match.stage?.type === 'SINGLE_ELIM' && match.stage?.tier === tier);
|
||||||
|
if (!tierMatches.length) return fetchBrackets();
|
||||||
|
// Group by round
|
||||||
|
const rounds = groupMatchesByRound(tierMatches);
|
||||||
|
const latestRound = rounds[rounds.length - 1];
|
||||||
|
if (latestRound && latestRound.every(m => m.result) && latestRound.length > 1) {
|
||||||
|
// All results set, advance this tier (only if more than one match, i.e., not final)
|
||||||
|
await fetch(`/api/admin/schedule/singleelim/next?tier=${tier}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (onTierComplete) onTierComplete();
|
||||||
|
}
|
||||||
|
fetchBrackets();
|
||||||
|
})
|
||||||
|
.catch(() => fetchBrackets());
|
||||||
|
};
|
||||||
|
|
||||||
|
// On initial load, check all tiers for advancement
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return fetchBrackets();
|
||||||
|
fetch('/api/matches')
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject(res))
|
||||||
|
.then(async data => {
|
||||||
|
const tierMap = {};
|
||||||
|
data.forEach(match => {
|
||||||
|
if (match.stage?.type === 'SINGLE_ELIM') {
|
||||||
|
const tier = match.stage.tier || 1;
|
||||||
|
if (!tierMap[tier]) tierMap[tier] = [];
|
||||||
|
tierMap[tier].push(match);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const tierNumbers = Object.keys(tierMap).map(Number);
|
||||||
|
for (const tier of tierNumbers) {
|
||||||
|
const matches = tierMap[tier];
|
||||||
|
const rounds = groupMatchesByRound(matches);
|
||||||
|
const latestRound = rounds[rounds.length - 1];
|
||||||
|
if (latestRound && latestRound.every(m => m.result) && latestRound.length > 1) {
|
||||||
|
// All results set, advance this tier
|
||||||
|
await fetch(`/api/admin/schedule/singleelim/next?tier=${tier}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchBrackets();
|
||||||
|
})
|
||||||
|
.catch(() => fetchBrackets());
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const openModal = (match) => {
|
||||||
|
setEditingMatch(match);
|
||||||
|
setScore1('');
|
||||||
|
setScore2('');
|
||||||
|
setSubmitMsg('');
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditingMatch(null);
|
||||||
|
setScore1('');
|
||||||
|
setScore2('');
|
||||||
|
setSubmitMsg('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitMsg('');
|
||||||
|
// Validation
|
||||||
|
if (score1 === '' || score2 === '') {
|
||||||
|
setSubmitMsg('Both scores are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[0-9]+$/.test(score1) || !/^[0-9]+$/.test(score2)) {
|
||||||
|
setSubmitMsg('Scores must be non-negative integers.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number(score1) < 0 || Number(score2) < 0) {
|
||||||
|
setSubmitMsg('Scores must be non-negative.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (score1 === score2) {
|
||||||
|
setSubmitMsg('Scores must be different (no draws).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
setSubmitMsg('You must be logged in as admin to submit results.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/matches/${editingMatch.id}/result`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ team1Score: Number(score1), team2Score: Number(score2) })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setSubmitMsg('Result saved!');
|
||||||
|
closeModal();
|
||||||
|
// After saving, refetch and check if the latest round for this tier is complete
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchBracketsAndAdvance(editingMatch.stage?.tier);
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
setSubmitMsg(data.error || 'Failed to save result');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSubmitMsg('Failed to save result');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div style={{ padding: 32 }}>Loading brackets...</div>;
|
||||||
|
if (error) return <div style={{ padding: 32, color: 'red' }}>{error}</div>;
|
||||||
|
if (!tiers.length) return <div style={{ padding: 32 }}>No elimination brackets found.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bracket-container">
|
||||||
|
<h2>Elimination Brackets by Tier</h2>
|
||||||
|
<div className="bracket-flex">
|
||||||
|
{tiers.map((tierObj, idx) => {
|
||||||
|
const rounds = groupMatchesByRound(tierObj.matches);
|
||||||
|
const bgColor = tierColors[idx % tierColors.length];
|
||||||
|
return (
|
||||||
|
<div className="bracket-round" key={tierObj.tier} style={{ background: bgColor }}>
|
||||||
|
<div className="bracket-round-title">{`Tier ${tierObj.tier}`}</div>
|
||||||
|
{rounds.map((round, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div style={{ fontWeight: 'bold', margin: '8px 0', textAlign: 'center' }}>{`Round ${i + 1}`}</div>
|
||||||
|
{round.map(match => (
|
||||||
|
<div className="bracket-match" key={match.id}>
|
||||||
|
<div className="bracket-player">
|
||||||
|
<TeamLogo team={match.team1} size="small" />
|
||||||
|
</div>
|
||||||
|
<div className="bracket-vs">vs</div>
|
||||||
|
<div className="bracket-player">
|
||||||
|
<TeamLogo team={match.team2} size="small" />
|
||||||
|
</div>
|
||||||
|
{match.result ? (
|
||||||
|
<div className="bracket-winner">Winner: {match.result.winnerId === match.team1?.id ? match.team1?.name : match.team2?.name}</div>
|
||||||
|
) : (
|
||||||
|
token && <button className="bracket-edit-btn" onClick={() => openModal(match)}>
|
||||||
|
Enter Result
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{modalOpen && (
|
||||||
|
<div className="bracket-modal-overlay" onClick={closeModal}>
|
||||||
|
<div className="bracket-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>Enter Result</h3>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<strong>{editingMatch?.team1?.name || 'TBD'}</strong> vs <strong>{editingMatch?.team2?.name || 'TBD'}</strong>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={score1}
|
||||||
|
onChange={e => setScore1(e.target.value)}
|
||||||
|
placeholder="Team 1 Score"
|
||||||
|
autoFocus
|
||||||
|
min="0"
|
||||||
|
style={{ padding: '0.5rem', borderRadius: 4, border: '1px solid #ccc' }}
|
||||||
|
disabled={!token}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={score2}
|
||||||
|
onChange={e => setScore2(e.target.value)}
|
||||||
|
placeholder="Team 2 Score"
|
||||||
|
min="0"
|
||||||
|
style={{ padding: '0.5rem', borderRadius: 4, border: '1px solid #ccc' }}
|
||||||
|
disabled={!token}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="bracket-edit-btn" disabled={!token}>Save</button>
|
||||||
|
<button type="button" className="bracket-edit-btn" onClick={closeModal} style={{ background: '#e5e7eb', color: '#222' }}>Cancel</button>
|
||||||
|
{!token && <div style={{ color: 'red', marginTop: 4 }}>You must be logged in as admin to submit results.</div>}
|
||||||
|
<div style={{ color: '#2563eb', marginTop: 4 }}>{submitMsg}</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Bracket;
|
||||||
66
tournament-frontend/src/ChangePassword.js
Normal file
66
tournament-frontend/src/ChangePassword.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const ChangePassword = ({ token }) => {
|
||||||
|
const [oldPassword, setOldPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/change-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ oldPassword, newPassword })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage('Password changed successfully!');
|
||||||
|
setOldPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
} else {
|
||||||
|
setMessage(data.error || 'Failed to change password');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage('Failed to change password');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token) return <div>Please log in.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Old Password"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={e => setOldPassword(e.target.value)}
|
||||||
|
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="New Password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={loading}>{loading ? 'Changing...' : 'Change Password'}</button>
|
||||||
|
<div style={{ marginTop: 8, color: '#2563eb' }}>{message}</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChangePassword;
|
||||||
54
tournament-frontend/src/ForgotPassword.js
Normal file
54
tournament-frontend/src/ForgotPassword.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const ForgotPassword = ({ onResetRequested }) => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/request-password-reset', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage('If the email exists, a reset link has been sent (see backend console).');
|
||||||
|
setEmail('');
|
||||||
|
if (onResetRequested) onResetRequested();
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Failed to request reset');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to request reset');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
|
||||||
|
<h2>Forgot Password</h2>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)}
|
||||||
|
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={loading}>{loading ? 'Sending...' : 'Send Reset Link'}</button>
|
||||||
|
<div style={{ marginTop: 8, color: 'green' }}>{message}</div>
|
||||||
|
<div style={{ marginTop: 8, color: 'red' }}>{error}</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForgotPassword;
|
||||||
60
tournament-frontend/src/Header.js
Normal file
60
tournament-frontend/src/Header.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Header = ({ tournamentName }) => {
|
||||||
|
return (
|
||||||
|
<header style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '60px',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 20px',
|
||||||
|
zIndex: 1000,
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.1)'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
backgroundColor: '#2563eb',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}>
|
||||||
|
T
|
||||||
|
</div>
|
||||||
|
<h1 style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600',
|
||||||
|
margin: 0,
|
||||||
|
color: '#1e293b'
|
||||||
|
}}>
|
||||||
|
{tournamentName || 'Tournament Manager'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
color: '#64748b',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
{new Date().toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
67
tournament-frontend/src/Leaderboard.css
Normal file
67
tournament-frontend/src/Leaderboard.css
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
.leaderboard-container {
|
||||||
|
margin: 2rem auto;
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-filter {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table th, .leaderboard-table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table th {
|
||||||
|
background: #f5f5f5;
|
||||||
|
font-weight: bold;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table th.active-sort {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table th:hover {
|
||||||
|
background: #e0e7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaderboard-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.first-place {
|
||||||
|
background: #fffbe6;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #bfa100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.second-place {
|
||||||
|
background: #f0f4ff;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #7d8799;
|
||||||
|
}
|
||||||
|
|
||||||
|
.third-place {
|
||||||
|
background: #fff4e6;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #b87333;
|
||||||
|
}
|
||||||
86
tournament-frontend/src/Leaderboard.js
Normal file
86
tournament-frontend/src/Leaderboard.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import leaderboardData from './leaderboardData';
|
||||||
|
import './Leaderboard.css';
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'rank', label: 'Rank' },
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'score', label: 'Score' },
|
||||||
|
{ key: 'matchesPlayed', label: 'Matches Played' },
|
||||||
|
{ key: 'wins', label: 'Wins' },
|
||||||
|
{ key: 'losses', label: 'Losses' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Leaderboard = () => {
|
||||||
|
const [sortKey, setSortKey] = useState('rank');
|
||||||
|
const [sortOrder, setSortOrder] = useState('asc');
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
|
||||||
|
const handleSort = (key) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortOrder('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredData = leaderboardData.filter(entry =>
|
||||||
|
entry.name.toLowerCase().includes(filter.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedData = [...filteredData].sort((a, b) => {
|
||||||
|
if (a[sortKey] < b[sortKey]) return sortOrder === 'asc' ? -1 : 1;
|
||||||
|
if (a[sortKey] > b[sortKey]) return sortOrder === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="leaderboard-container">
|
||||||
|
<h2>Leaderboard</h2>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="leaderboard-filter"
|
||||||
|
name="leaderboard-filter"
|
||||||
|
placeholder="Filter by name..."
|
||||||
|
value={filter}
|
||||||
|
onChange={e => setFilter(e.target.value)}
|
||||||
|
className="leaderboard-filter"
|
||||||
|
/>
|
||||||
|
<table className="leaderboard-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(col => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
onClick={() => handleSort(col.key)}
|
||||||
|
className={sortKey === col.key ? 'active-sort' : ''}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{col.label} {sortKey === col.key ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedData.map((entry, idx) => (
|
||||||
|
<tr key={entry.rank} className={
|
||||||
|
entry.rank === 1 ? 'first-place' :
|
||||||
|
entry.rank === 2 ? 'second-place' :
|
||||||
|
entry.rank === 3 ? 'third-place' : ''
|
||||||
|
}>
|
||||||
|
<td>{entry.rank}</td>
|
||||||
|
<td>{entry.name}</td>
|
||||||
|
<td>{entry.score}</td>
|
||||||
|
<td>{entry.matchesPlayed}</td>
|
||||||
|
<td>{entry.wins}</td>
|
||||||
|
<td>{entry.losses}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Leaderboard;
|
||||||
60
tournament-frontend/src/Login.js
Normal file
60
tournament-frontend/src/Login.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const Login = ({ onLogin }) => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.token) {
|
||||||
|
onLogin(data.token);
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Login failed');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Login failed');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
|
||||||
|
<h2>Admin Login</h2>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={loading}>{loading ? 'Logging in...' : 'Login'}</button>
|
||||||
|
<div style={{ marginTop: 8, color: 'red' }}>{error}</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
112
tournament-frontend/src/MatchesSchedule.css
Normal file
112
tournament-frontend/src/MatchesSchedule.css
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
.matches-schedule-container {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matches-rounds-flex {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: flex-start;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matches-round {
|
||||||
|
min-width: 260px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matches-round-title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matches-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-stage {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-players {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vs {
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-time {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-result {
|
||||||
|
color: #22c55e;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-edit-btn {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-edit-form input {
|
||||||
|
width: 60px;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-edit-form button {
|
||||||
|
margin: 2px 4px;
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-edit-msg {
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
240
tournament-frontend/src/MatchesSchedule.js
Normal file
240
tournament-frontend/src/MatchesSchedule.js
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import './MatchesSchedule.css';
|
||||||
|
import TeamLogo from './TeamLogo';
|
||||||
|
|
||||||
|
const poolColors = [
|
||||||
|
'#f8fafc', // light blue
|
||||||
|
'#fef9c3', // light yellow
|
||||||
|
'#fce7f3', // light pink
|
||||||
|
'#d1fae5', // light green
|
||||||
|
'#fee2e2', // light red
|
||||||
|
'#e0e7ff', // light purple
|
||||||
|
];
|
||||||
|
|
||||||
|
function groupMatchesByPoolAndRound(matches) {
|
||||||
|
// Only round robin matches
|
||||||
|
const rrMatches = matches.filter(m => m.stage?.type === 'ROUND_ROBIN');
|
||||||
|
let poolMap = {};
|
||||||
|
rrMatches.forEach(m => {
|
||||||
|
const pool = m.pool || 1;
|
||||||
|
if (!poolMap[pool]) poolMap[pool] = [];
|
||||||
|
poolMap[pool].push(m);
|
||||||
|
});
|
||||||
|
// For each pool, group matches by round (scheduledAt)
|
||||||
|
const poolRounds = {};
|
||||||
|
Object.entries(poolMap).forEach(([pool, matches]) => {
|
||||||
|
const rounds = {};
|
||||||
|
matches.forEach(match => {
|
||||||
|
const key = match.scheduledAt;
|
||||||
|
if (!rounds[key]) rounds[key] = [];
|
||||||
|
rounds[key].push(match);
|
||||||
|
});
|
||||||
|
poolRounds[pool] = Object.values(rounds).sort((a, b) => new Date(a[0].scheduledAt) - new Date(b[0].scheduledAt));
|
||||||
|
});
|
||||||
|
return poolRounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const MatchesSchedule = () => {
|
||||||
|
const [matches, setMatches] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [editingMatchId, setEditingMatchId] = useState(null);
|
||||||
|
const [score1, setScore1] = useState('');
|
||||||
|
const [score2, setScore2] = useState('');
|
||||||
|
const [submitMsg, setSubmitMsg] = useState('');
|
||||||
|
|
||||||
|
const [mobileTab, setMobileTab] = useState(null);
|
||||||
|
|
||||||
|
// Calculate poolRounds and poolNumbers BEFORE any useEffect/useState that uses them
|
||||||
|
const poolRounds = groupMatchesByPoolAndRound(matches);
|
||||||
|
const poolNumbers = Object.keys(poolRounds).sort((a, b) => Number(a) - Number(b));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch('/api/matches')
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject(res))
|
||||||
|
.then(data => {
|
||||||
|
setMatches(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Failed to load matches');
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set default mobile tab
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (mobileTab === null && poolNumbers.length > 0) setMobileTab(poolNumbers[0]);
|
||||||
|
}, [poolNumbers, mobileTab]);
|
||||||
|
|
||||||
|
// Responsive: tabs for mobile, columns for desktop
|
||||||
|
const isMobile = window.innerWidth < 700;
|
||||||
|
|
||||||
|
const handleEdit = (match) => {
|
||||||
|
setEditingMatchId(match.id);
|
||||||
|
setScore1('');
|
||||||
|
setScore2('');
|
||||||
|
setSubmitMsg('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (match) => {
|
||||||
|
setSubmitMsg('');
|
||||||
|
if (score1 === '' || score2 === '' || score1 === score2) {
|
||||||
|
setSubmitMsg('Scores must be different and not empty.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/matches/${match.id}/result`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ team1Score: Number(score1), team2Score: Number(score2) })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setSubmitMsg('Result saved!');
|
||||||
|
setEditingMatchId(null);
|
||||||
|
setLoading(true);
|
||||||
|
fetch('/api/matches')
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject(res))
|
||||||
|
.then(data => {
|
||||||
|
setMatches(data);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSubmitMsg(data.error || 'Failed to save result');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSubmitMsg('Failed to save result');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div style={{ padding: 32 }}>Loading matches...</div>;
|
||||||
|
if (error) return <div style={{ padding: 32, color: 'red' }}>{error}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="matches-schedule-container">
|
||||||
|
<h2>Matches Schedule (by Pool)</h2>
|
||||||
|
{isMobile ? (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginBottom: 24 }}>
|
||||||
|
{poolNumbers.map((pool, idx) => (
|
||||||
|
<button
|
||||||
|
key={pool}
|
||||||
|
onClick={() => setMobileTab(pool)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: 'none',
|
||||||
|
background: mobileTab === pool ? poolColors[idx % poolColors.length] : '#e5e7eb',
|
||||||
|
color: '#222',
|
||||||
|
fontWeight: mobileTab === pool ? 'bold' : 'normal',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: mobileTab === pool ? '0 2px 8px rgba(0,0,0,0.08)' : 'none',
|
||||||
|
outline: mobileTab === pool ? '2px solid #2563eb' : 'none',
|
||||||
|
transition: 'background 0.2s, box-shadow 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`Pool ${pool}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{poolNumbers.map((pool, idx) => (
|
||||||
|
mobileTab === pool && (
|
||||||
|
<div key={pool} style={{
|
||||||
|
minWidth: 320,
|
||||||
|
background: poolColors[idx % poolColors.length],
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
|
||||||
|
marginBottom: 24
|
||||||
|
}}>
|
||||||
|
<h3 style={{ textAlign: 'center' }}>{`Pool ${pool}`}</h3>
|
||||||
|
{poolRounds[pool].map((round, roundIdx) => (
|
||||||
|
<div key={roundIdx} className="matches-grid">
|
||||||
|
<div style={{ fontWeight: 'bold', margin: '8px 0', textAlign: 'center' }}>{`Round ${roundIdx + 1}`}</div>
|
||||||
|
{round.map(match => (
|
||||||
|
<div className="match-card" key={match.id}>
|
||||||
|
<div className="match-players">
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<TeamLogo team={match.team1} size="small" />
|
||||||
|
</div>
|
||||||
|
<span className="vs">vs</span>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<TeamLogo team={match.team2} size="small" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="match-time">{new Date(match.scheduledAt).toLocaleString()}</div>
|
||||||
|
{match.result ? (
|
||||||
|
<div className="match-result">Result: {match.result.team1Score} - {match.result.team2Score}</div>
|
||||||
|
) : (
|
||||||
|
editingMatchId === match.id ? (
|
||||||
|
<div className="match-edit-form">
|
||||||
|
<input type="number" value={score1} onChange={e => setScore1(e.target.value)} placeholder="Team 1 Score" />
|
||||||
|
<input type="number" value={score2} onChange={e => setScore2(e.target.value)} placeholder="Team 2 Score" />
|
||||||
|
<button onClick={() => handleSubmit(match)}>Save</button>
|
||||||
|
<button onClick={() => setEditingMatchId(null)}>Cancel</button>
|
||||||
|
<div className="match-edit-msg">{submitMsg}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="match-edit-btn" onClick={() => handleEdit(match)}>Enter Result</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: 32, alignItems: 'flex-start', overflowX: 'auto' }}>
|
||||||
|
{poolNumbers.map((pool, idx) => (
|
||||||
|
<div className="matches-round" key={pool} style={{ minWidth: 320, background: poolColors[idx % poolColors.length], borderRadius: 8, padding: 16, boxShadow: '0 2px 8px rgba(0,0,0,0.04)', marginBottom: 24 }}>
|
||||||
|
<div className="matches-round-title">{`Pool ${pool}`}</div>
|
||||||
|
{poolRounds[pool].map((round, roundIdx) => (
|
||||||
|
<div key={roundIdx} className="matches-grid">
|
||||||
|
<div style={{ fontWeight: 'bold', margin: '8px 0', textAlign: 'center' }}>{`Round ${roundIdx + 1}`}</div>
|
||||||
|
{round.map(match => (
|
||||||
|
<div className="match-card" key={match.id}>
|
||||||
|
<div className="match-players">
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<TeamLogo team={match.team1} size="small" />
|
||||||
|
</div>
|
||||||
|
<span className="vs">vs</span>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<TeamLogo team={match.team2} size="small" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="match-time">{new Date(match.scheduledAt).toLocaleString()}</div>
|
||||||
|
{match.result ? (
|
||||||
|
<div className="match-result">Result: {match.result.team1Score} - {match.result.team2Score}</div>
|
||||||
|
) : (
|
||||||
|
editingMatchId === match.id ? (
|
||||||
|
<div className="match-edit-form">
|
||||||
|
<input type="number" value={score1} onChange={e => setScore1(e.target.value)} placeholder="Team 1 Score" />
|
||||||
|
<input type="number" value={score2} onChange={e => setScore2(e.target.value)} placeholder="Team 2 Score" />
|
||||||
|
<button onClick={() => handleSubmit(match)}>Save</button>
|
||||||
|
<button onClick={() => setEditingMatchId(null)}>Cancel</button>
|
||||||
|
<div className="match-edit-msg">{submitMsg}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button className="match-edit-btn" onClick={() => handleEdit(match)}>Enter Result</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatchesSchedule;
|
||||||
237
tournament-frontend/src/Pools.js
Normal file
237
tournament-frontend/src/Pools.js
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import TeamLogo from './TeamLogo';
|
||||||
|
|
||||||
|
const poolColors = [
|
||||||
|
'#f8fafc', // light blue
|
||||||
|
'#fef9c3', // light yellow
|
||||||
|
'#fce7f3', // light pink
|
||||||
|
'#d1fae5', // light green
|
||||||
|
'#fee2e2', // light red
|
||||||
|
'#e0e7ff', // light purple
|
||||||
|
];
|
||||||
|
|
||||||
|
const Pools = ({ token, onTournamentNameChange }) => {
|
||||||
|
const [matches, setMatches] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [teamName, setTeamName] = useState('');
|
||||||
|
const [registerMsg, setRegisterMsg] = useState('');
|
||||||
|
const [registrationDisabled, setRegistrationDisabled] = useState(false);
|
||||||
|
|
||||||
|
const [tournamentName, setTournamentName] = useState(() => localStorage.getItem('tournamentName') || '');
|
||||||
|
|
||||||
|
const fetchMatches = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/matches');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setMatches(data.filter(m => m.stage?.type === 'ROUND_ROBIN'));
|
||||||
|
setRegistrationDisabled(data.some(m => m.stage?.type === 'ROUND_ROBIN'));
|
||||||
|
} else {
|
||||||
|
setError('Failed to load matches');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load matches');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMatches();
|
||||||
|
}, [registerMsg]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('tournamentName', tournamentName);
|
||||||
|
if (onTournamentNameChange) onTournamentNameChange(tournamentName);
|
||||||
|
}, [tournamentName, onTournamentNameChange]);
|
||||||
|
|
||||||
|
// Group matches by pool
|
||||||
|
const poolMap = {};
|
||||||
|
matches.forEach(match => {
|
||||||
|
const pool = match.pool || 1;
|
||||||
|
if (!poolMap[pool]) poolMap[pool] = [];
|
||||||
|
poolMap[pool].push(match);
|
||||||
|
});
|
||||||
|
const poolNumbers = Object.keys(poolMap).sort((a, b) => Number(a) - Number(b));
|
||||||
|
|
||||||
|
// Get all teams per pool
|
||||||
|
const poolTeams = {};
|
||||||
|
poolNumbers.forEach(pool => {
|
||||||
|
const teams = {};
|
||||||
|
poolMap[pool].forEach(match => {
|
||||||
|
if (match.team1) teams[match.team1.id] = match.team1;
|
||||||
|
if (match.team2) teams[match.team2.id] = match.team2;
|
||||||
|
});
|
||||||
|
poolTeams[pool] = Object.values(teams);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set default active tab
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (activeTab === null && poolNumbers.length > 0) setActiveTab(poolNumbers[0]);
|
||||||
|
}, [poolNumbers, activeTab]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
<h2>Pools</h2>
|
||||||
|
{token && !registrationDisabled && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tournamentName}
|
||||||
|
onChange={e => setTournamentName(e.target.value)}
|
||||||
|
placeholder="Tournament Name"
|
||||||
|
style={{ padding: '0.5rem', borderRadius: 4, border: '1px solid #ccc', minWidth: 220, fontWeight: 'bold', fontSize: '1.1rem' }}
|
||||||
|
disabled={registrationDisabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{token && !registrationDisabled && (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(f => !f)}
|
||||||
|
style={{
|
||||||
|
background: '#2563eb', color: '#fff', border: 'none', borderRadius: 6, padding: '8px 20px', cursor: 'pointer', fontWeight: 'bold', marginBottom: 8
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showForm ? 'Cancel' : 'Register Team'}
|
||||||
|
</button>
|
||||||
|
{showForm && (
|
||||||
|
<form
|
||||||
|
onSubmit={async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
setRegisterMsg('');
|
||||||
|
if (!teamName.trim()) {
|
||||||
|
setRegisterMsg('Team name is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/teams', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ name: teamName.trim() })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setTeamName('');
|
||||||
|
setShowForm(false);
|
||||||
|
|
||||||
|
if (data.poolsCreated) {
|
||||||
|
setRegisterMsg(`✅ Team registered! ${data.poolMessage} (${data.matchesCreated} matches created)`);
|
||||||
|
} else {
|
||||||
|
setRegisterMsg(`✅ Team registered! ${data.poolMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the matches data to show the new pools
|
||||||
|
fetchMatches();
|
||||||
|
} else {
|
||||||
|
setRegisterMsg(data.error || 'Failed to register team');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setRegisterMsg('Failed to register team');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ marginTop: 8, display: 'flex', gap: 8, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={teamName}
|
||||||
|
onChange={e => setTeamName(e.target.value)}
|
||||||
|
placeholder="Team Name"
|
||||||
|
style={{ padding: '0.5rem', borderRadius: 4, border: '1px solid #ccc', minWidth: 180 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" style={{ background: '#22c55e', color: '#fff', border: 'none', borderRadius: 4, padding: '8px 16px', fontWeight: 'bold', cursor: 'pointer' }}>Submit</button>
|
||||||
|
<span style={{ color: registerMsg.startsWith('Team registered') ? '#22c55e' : 'red', marginLeft: 8 }}>{registerMsg}</span>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{token && registrationDisabled && (
|
||||||
|
<div style={{ marginBottom: 24, color: '#b91c1c', fontWeight: 'bold' }}>
|
||||||
|
Team registration is disabled after the tournament has started.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading && <div>Loading pools...</div>}
|
||||||
|
{error && <div style={{ color: 'red' }}>{error}</div>}
|
||||||
|
{/* Tabs for pools */}
|
||||||
|
<div style={{ display: 'flex', gap: 12, marginBottom: 24 }}>
|
||||||
|
{poolNumbers.map((pool, idx) => (
|
||||||
|
<button
|
||||||
|
key={pool}
|
||||||
|
onClick={() => setActiveTab(pool)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: 'none',
|
||||||
|
background: activeTab === pool ? poolColors[idx % poolColors.length] : '#e5e7eb',
|
||||||
|
color: '#222',
|
||||||
|
fontWeight: activeTab === pool ? 'bold' : 'normal',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: activeTab === pool ? '0 2px 8px rgba(0,0,0,0.08)' : 'none',
|
||||||
|
outline: activeTab === pool ? '2px solid #2563eb' : 'none',
|
||||||
|
transition: 'background 0.2s, box-shadow 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`Pool ${pool}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Table view for active pool */}
|
||||||
|
{poolNumbers.map((pool, idx) => (
|
||||||
|
activeTab === pool && (
|
||||||
|
<div key={pool} style={{
|
||||||
|
minWidth: 320,
|
||||||
|
background: poolColors[idx % poolColors.length],
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 16,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
|
||||||
|
marginBottom: 24
|
||||||
|
}}>
|
||||||
|
<h3 style={{ textAlign: 'center' }}>{`Pool ${pool}`}</h3>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<strong>Teams:</strong>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: '12px', marginTop: '8px' }}>
|
||||||
|
{poolTeams[pool].map(team => (
|
||||||
|
<div key={team.id} style={{ textAlign: 'center' }}>
|
||||||
|
<TeamLogo team={team} size="small" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: 6 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Team 1</th>
|
||||||
|
<th>Team 2</th>
|
||||||
|
<th>Result</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{poolMap[pool].map(match => (
|
||||||
|
<tr key={match.id}>
|
||||||
|
<td style={{ textAlign: 'center', padding: '8px' }}>
|
||||||
|
<TeamLogo team={match.team1} size="small" />
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'center', padding: '8px' }}>
|
||||||
|
<TeamLogo team={match.team2} size="small" />
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'center', fontWeight: 'bold' }}>
|
||||||
|
{match.result ? `${match.result.team1Score} - ${match.result.team2Score}` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Pools;
|
||||||
94
tournament-frontend/src/ProfileEdit.js
Normal file
94
tournament-frontend/src/ProfileEdit.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
|
|
||||||
|
const ProfileEdit = ({ token }) => {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [imageUrl, setImageUrl] = useState('');
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const fileInputRef = useRef();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
fetch('/api/me', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject(res))
|
||||||
|
.then(data => {
|
||||||
|
setUser(data);
|
||||||
|
setName(data.name);
|
||||||
|
setImageUrl(data.imageUrl || '');
|
||||||
|
});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const handleImageUpload = async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploading(true);
|
||||||
|
setMessage('');
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/upload-image', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Upload failed');
|
||||||
|
const data = await res.json();
|
||||||
|
setImageUrl(data.imageUrl);
|
||||||
|
setMessage('Image uploaded!');
|
||||||
|
} catch {
|
||||||
|
setMessage('Failed to upload image');
|
||||||
|
}
|
||||||
|
setUploading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameChange = (e) => setName(e.target.value);
|
||||||
|
const handleSave = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/update-name', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setUser(data);
|
||||||
|
setMessage('Name updated!');
|
||||||
|
} else {
|
||||||
|
setMessage(data.error || 'Failed to update name');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage('Failed to update name');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token) return <div>Please log in.</div>;
|
||||||
|
if (!user) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSave} style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
|
||||||
|
<div>
|
||||||
|
{imageUrl && (
|
||||||
|
<img src={imageUrl} alt="Profile" style={{ width: 120, height: 120, borderRadius: '50%', objectFit: 'cover', marginBottom: 16 }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="file" accept="image/*" ref={fileInputRef} onChange={handleImageUpload} disabled={uploading} />
|
||||||
|
</div>
|
||||||
|
<div style={{ margin: '1rem 0' }}>
|
||||||
|
<input type="text" value={name} onChange={handleNameChange} placeholder="Name" style={{ padding: '0.5rem', width: '80%' }} />
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={uploading}>{uploading ? 'Uploading...' : 'Save'}</button>
|
||||||
|
<div style={{ marginTop: 8, color: '#2563eb' }}>{message}</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileEdit;
|
||||||
41
tournament-frontend/src/ProfileView.js
Normal file
41
tournament-frontend/src/ProfileView.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const ProfileView = ({ token }) => {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
fetch('/api/me', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
})
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject(res))
|
||||||
|
.then(data => {
|
||||||
|
setUser(data);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Failed to load profile');
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
if (!token) return <div>Please log in.</div>;
|
||||||
|
if (loading) return <div>Loading profile...</div>;
|
||||||
|
if (error) return <div>{error}</div>;
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
|
||||||
|
{user.imageUrl && (
|
||||||
|
<img src={user.imageUrl} alt="Profile" style={{ width: 120, height: 120, borderRadius: '50%', objectFit: 'cover', marginBottom: 16 }} />
|
||||||
|
)}
|
||||||
|
<h2>{user.name}</h2>
|
||||||
|
<p>{user.email}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileView;
|
||||||
84
tournament-frontend/src/Register.js
Normal file
84
tournament-frontend/src/Register.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const Register = ({ onRegister, onBackToLogin }) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, username, password })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
// Auto-login: call onRegister with username and password
|
||||||
|
if (onRegister) onRegister(username, password);
|
||||||
|
setSuccess('Registration successful! Logging in...');
|
||||||
|
setName('');
|
||||||
|
setUsername('');
|
||||||
|
setPassword('');
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Registration failed');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Registration failed');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
|
||||||
|
<h2>Register</h2>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={loading}>{loading ? 'Registering...' : 'Register'}</button>
|
||||||
|
<div style={{ marginTop: 8, color: 'green' }}>{success}</div>
|
||||||
|
<div style={{ marginTop: 8, color: 'red' }}>{error}</div>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<button type="button" style={{ background: 'none', border: 'none', color: '#2563eb', cursor: 'pointer', textDecoration: 'underline' }} onClick={onBackToLogin}>
|
||||||
|
Back to Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Register;
|
||||||
64
tournament-frontend/src/ResetPassword.js
Normal file
64
tournament-frontend/src/ResetPassword.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const ResetPassword = ({ token: propToken }) => {
|
||||||
|
const [token, setToken] = useState(propToken || '');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token, newPassword })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setMessage('Password has been reset! You can now log in.');
|
||||||
|
setNewPassword('');
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Failed to reset password');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Failed to reset password');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} style={{ maxWidth: 400, margin: '2rem auto', textAlign: 'center' }}>
|
||||||
|
<h2>Reset Password</h2>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Reset Token"
|
||||||
|
value={token}
|
||||||
|
onChange={e => setToken(e.target.value)}
|
||||||
|
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="New Password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={e => setNewPassword(e.target.value)}
|
||||||
|
style={{ padding: '0.5rem', width: '80%', marginBottom: 8 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={loading}>{loading ? 'Resetting...' : 'Reset Password'}</button>
|
||||||
|
<div style={{ marginTop: 8, color: 'green' }}>{message}</div>
|
||||||
|
<div style={{ marginTop: 8, color: 'red' }}>{error}</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResetPassword;
|
||||||
68
tournament-frontend/src/Results.js
Normal file
68
tournament-frontend/src/Results.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import TeamLogo from './TeamLogo';
|
||||||
|
|
||||||
|
const Results = () => {
|
||||||
|
const [matches, setMatches] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch('/api/matches')
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject(res))
|
||||||
|
.then(data => {
|
||||||
|
setMatches(data.filter(m => m.result));
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Failed to load results');
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div style={{ padding: 32 }}>Loading results...</div>;
|
||||||
|
if (error) return <div style={{ padding: 32, color: 'red' }}>{error}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32 }}>
|
||||||
|
<h2>Results</h2>
|
||||||
|
<table style={{ width: '100%', marginTop: 16, borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Stage</th>
|
||||||
|
<th>Team 1</th>
|
||||||
|
<th>Team 2</th>
|
||||||
|
<th>Score</th>
|
||||||
|
<th>Winner</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{matches.map(match => {
|
||||||
|
const winnerId = match.result?.winnerId;
|
||||||
|
return (
|
||||||
|
<tr key={match.id}>
|
||||||
|
<td>{match.stage?.type || ''}</td>
|
||||||
|
<td style={{ textAlign: 'center', padding: '8px' }}>
|
||||||
|
<TeamLogo team={match.team1} size="small" />
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: 'center', padding: '8px' }}>
|
||||||
|
<TeamLogo team={match.team2} size="small" />
|
||||||
|
</td>
|
||||||
|
<td>{match.result ? `${match.result.team1Score} - ${match.result.team2Score}` : ''}</td>
|
||||||
|
<td>
|
||||||
|
{match.result ? (
|
||||||
|
<span style={{ color: 'green', fontWeight: 'bold' }}>
|
||||||
|
{winnerId === match.team1?.id ? match.team1?.name : match.team2?.name}
|
||||||
|
</span>
|
||||||
|
) : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Results;
|
||||||
31
tournament-frontend/src/Sidebar.css
Normal file
31
tournament-frontend/src/Sidebar.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
padding-top: 1rem;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 60px;
|
||||||
|
z-index: 10;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar li {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar li.active, .sidebar li:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
30
tournament-frontend/src/Sidebar.js
Normal file
30
tournament-frontend/src/Sidebar.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './Sidebar.css';
|
||||||
|
|
||||||
|
const Sidebar = ({ onSelect, selected }) => {
|
||||||
|
const links = [
|
||||||
|
{ key: 'pools', label: 'Pools' },
|
||||||
|
{ key: 'schedule', label: 'Matches Schedule' },
|
||||||
|
{ key: 'bracket', label: 'Bracket' },
|
||||||
|
{ key: 'results', label: 'Results' },
|
||||||
|
{ key: 'team-ranking', label: 'Team Ranking' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="sidebar">
|
||||||
|
<ul>
|
||||||
|
{links.map(link => (
|
||||||
|
<li
|
||||||
|
key={link.key}
|
||||||
|
className={selected === link.key ? 'active' : ''}
|
||||||
|
onClick={() => onSelect(link.key)}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
55
tournament-frontend/src/TeamLogo.js
Normal file
55
tournament-frontend/src/TeamLogo.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const TeamLogo = ({ team, size = 'medium', showName = true, style = {} }) => {
|
||||||
|
if (!team) return null;
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
small: { fontSize: '16px', marginBottom: '2px' },
|
||||||
|
medium: { fontSize: '24px', marginBottom: '4px' },
|
||||||
|
large: { fontSize: '32px', marginBottom: '6px' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const logoStyle = {
|
||||||
|
display: 'block',
|
||||||
|
textAlign: 'center',
|
||||||
|
...sizeStyles[size],
|
||||||
|
...style
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if logo is SVG
|
||||||
|
const isSVG = team.logo && team.logo.trim().startsWith('<svg');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
{team.logo && (
|
||||||
|
<div style={logoStyle}>
|
||||||
|
{isSVG ? (
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{ __html: team.logo }}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: size === 'small' ? '24px' : size === 'large' ? '40px' : '32px',
|
||||||
|
height: size === 'small' ? '24px' : size === 'large' ? '40px' : '32px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: sizeStyles[size].fontSize }}>
|
||||||
|
{team.logo}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showName && (
|
||||||
|
<div style={{
|
||||||
|
fontSize: size === 'small' ? '12px' : size === 'large' ? '18px' : '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
{team.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamLogo;
|
||||||
120
tournament-frontend/src/TeamRanking.js
Normal file
120
tournament-frontend/src/TeamRanking.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import TeamLogo from './TeamLogo';
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'rank', label: 'Rank' },
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'points', label: 'Points' },
|
||||||
|
{ key: 'played', label: 'Matches Played' },
|
||||||
|
{ key: 'wins', label: 'Wins' },
|
||||||
|
{ key: 'losses', label: 'Losses' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TeamRanking = ({ refresh }) => {
|
||||||
|
const [standings, setStandings] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [sortKey, setSortKey] = useState('rank');
|
||||||
|
const [sortOrder, setSortOrder] = useState('asc');
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [isFinal, setIsFinal] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch('/api/standings')
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject(res))
|
||||||
|
.then(data => {
|
||||||
|
// Add rank field
|
||||||
|
const withRank = data.map((entry, idx) => ({ ...entry, rank: idx + 1 }));
|
||||||
|
setStandings(withRank);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Failed to load team rankings');
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
// Check if all matches are complete
|
||||||
|
fetch('/api/matches')
|
||||||
|
.then(res => res.ok ? res.json() : Promise.reject(res))
|
||||||
|
.then(data => {
|
||||||
|
// Tournament is final if all SINGLE_ELIM matches have a result
|
||||||
|
const singleElimMatches = data.filter(m => m.stage?.type === 'SINGLE_ELIM');
|
||||||
|
setIsFinal(singleElimMatches.length > 0 && singleElimMatches.every(m => m.result));
|
||||||
|
})
|
||||||
|
.catch(() => setIsFinal(false));
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const handleSort = (key) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortOrder('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredData = standings.filter(entry =>
|
||||||
|
entry.name.toLowerCase().includes(filter.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedData = [...filteredData].sort((a, b) => {
|
||||||
|
if (a[sortKey] < b[sortKey]) return sortOrder === 'asc' ? -1 : 1;
|
||||||
|
if (a[sortKey] > b[sortKey]) return sortOrder === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) return <div style={{ padding: 32 }}>Loading team rankings...</div>;
|
||||||
|
if (error) return <div style={{ padding: 32, color: 'red' }}>{error}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="leaderboard-container">
|
||||||
|
<h2>Team Ranking</h2>
|
||||||
|
{isFinal && <div style={{ color: '#22c55e', fontWeight: 'bold', fontSize: '1.2rem', marginBottom: 12 }}>Final Rankings</div>}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="team-ranking-filter"
|
||||||
|
name="team-ranking-filter"
|
||||||
|
placeholder="Filter by name..."
|
||||||
|
value={filter}
|
||||||
|
onChange={e => setFilter(e.target.value)}
|
||||||
|
className="leaderboard-filter"
|
||||||
|
/>
|
||||||
|
<table className="leaderboard-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(col => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
onClick={() => handleSort(col.key)}
|
||||||
|
className={sortKey === col.key ? 'active-sort' : ''}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{col.label} {sortKey === col.key ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedData.map((entry, idx) => (
|
||||||
|
<tr key={entry.id} className={
|
||||||
|
entry.rank === 1 ? 'first-place' :
|
||||||
|
entry.rank === 2 ? 'second-place' :
|
||||||
|
entry.rank === 3 ? 'third-place' : ''
|
||||||
|
}>
|
||||||
|
<td>{entry.rank}</td>
|
||||||
|
<td style={{ textAlign: 'center', padding: '8px' }}>
|
||||||
|
<TeamLogo team={{ name: entry.name, logo: entry.logo }} size="small" />
|
||||||
|
</td>
|
||||||
|
<td>{entry.points}</td>
|
||||||
|
<td>{entry.played}</td>
|
||||||
|
<td>{entry.wins}</td>
|
||||||
|
<td>{entry.losses}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamRanking;
|
||||||
86
tournament-frontend/src/UserMenu.css
Normal file
86
tournament-frontend/src/UserMenu.css
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
.user-menu {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-btn {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-btn:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 110%;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-dropdown li {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-dropdown li:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-tournament-option {
|
||||||
|
color: #166534;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-tournament-option:hover {
|
||||||
|
background: #bbf7d0;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-tournament-option.disabled {
|
||||||
|
background: none !important;
|
||||||
|
color: #166534 !important;
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-text {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
width: 240px;
|
||||||
|
background: #222;
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
position: absolute;
|
||||||
|
left: 110%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 1001;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-tournament-option.disabled:hover .tooltip-text,
|
||||||
|
.start-tournament-option.disabled:focus .tooltip-text {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
59
tournament-frontend/src/UserMenu.js
Normal file
59
tournament-frontend/src/UserMenu.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import './UserMenu.css';
|
||||||
|
|
||||||
|
const UserMenu = ({ onSelect, token, onResetTournament, tournamentStarted }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (option) => {
|
||||||
|
setOpen(false);
|
||||||
|
if (onSelect) onSelect(option);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="user-menu" ref={menuRef}>
|
||||||
|
<button className="user-menu-btn" onClick={() => setOpen(!open)}>
|
||||||
|
Admin Menu ▼
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<ul className="user-menu-dropdown">
|
||||||
|
{!token ? (
|
||||||
|
<li onClick={() => handleSelect('profile')}>Login</li>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<li onClick={() => handleSelect('create-tournament')}>Create tournament</li>
|
||||||
|
<li onClick={() => handleSelect('load-tournaments')}>Load tournaments</li>
|
||||||
|
<li
|
||||||
|
className={`start-tournament-option${tournamentStarted ? ' disabled' : ''}`}
|
||||||
|
onClick={tournamentStarted ? undefined : () => handleSelect('start-tournament')}
|
||||||
|
style={tournamentStarted ? { cursor: 'not-allowed', opacity: 0.6, position: 'relative' } : {}}
|
||||||
|
{...(tournamentStarted ? { 'data-tooltip': 'The tournament has already started. You cannot start it again.' } : {})}
|
||||||
|
>
|
||||||
|
Start tournament
|
||||||
|
{tournamentStarted && (
|
||||||
|
<span className="tooltip-text">The tournament has already started. You cannot start it again.</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
<li onClick={() => handleSelect('logout')}>Logout</li>
|
||||||
|
<li style={{ color: '#b91c1c', fontWeight: 'bold' }} onClick={() => { setOpen(false); if (onResetTournament) onResetTournament(); }}>Reset Tournament</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserMenu;
|
||||||
13
tournament-frontend/src/index.css
Normal file
13
tournament-frontend/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
23
tournament-frontend/src/index.js
Normal file
23
tournament-frontend/src/index.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
|
||||||
|
import reportWebVitals from './reportWebVitals';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
|
// If you want your app to work offline and load faster, you can change
|
||||||
|
// unregister() to register() below. Note this comes with some pitfalls.
|
||||||
|
// Learn more about service workers: https://cra.link/PWA
|
||||||
|
serviceWorkerRegistration.unregister();
|
||||||
|
|
||||||
|
// If you want to start measuring performance in your app, pass a function
|
||||||
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||||
|
reportWebVitals();
|
||||||
101
tournament-frontend/src/leaderboardData.js
Normal file
101
tournament-frontend/src/leaderboardData.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// Mock leaderboard data for development
|
||||||
|
const leaderboardData = [
|
||||||
|
{
|
||||||
|
rank: 1,
|
||||||
|
name: 'Team Alpha',
|
||||||
|
score: 15,
|
||||||
|
matchesPlayed: 5,
|
||||||
|
wins: 5,
|
||||||
|
losses: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 2,
|
||||||
|
name: 'Team Beta',
|
||||||
|
score: 12,
|
||||||
|
matchesPlayed: 5,
|
||||||
|
wins: 4,
|
||||||
|
losses: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 3,
|
||||||
|
name: 'Team Gamma',
|
||||||
|
score: 9,
|
||||||
|
matchesPlayed: 5,
|
||||||
|
wins: 3,
|
||||||
|
losses: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 4,
|
||||||
|
name: 'Team Delta',
|
||||||
|
score: 6,
|
||||||
|
matchesPlayed: 5,
|
||||||
|
wins: 2,
|
||||||
|
losses: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 5,
|
||||||
|
name: 'Team Epsilon',
|
||||||
|
score: 5,
|
||||||
|
matchesPlayed: 5,
|
||||||
|
wins: 1,
|
||||||
|
losses: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 6,
|
||||||
|
name: 'Team Zeta',
|
||||||
|
score: 4,
|
||||||
|
matchesPlayed: 5,
|
||||||
|
wins: 1,
|
||||||
|
losses: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 7,
|
||||||
|
name: 'Team Eta',
|
||||||
|
score: 3,
|
||||||
|
matchesPlayed: 5,
|
||||||
|
wins: 1,
|
||||||
|
losses: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 8,
|
||||||
|
name: 'Team Theta',
|
||||||
|
score: 2,
|
||||||
|
matchesPlayed: 5,
|
||||||
|
wins: 0,
|
||||||
|
losses: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 9,
|
||||||
|
name: 'Team Iota',
|
||||||
|
score: 2,
|
||||||
|
matchesPlayed: 5,
|
||||||
|
wins: 0,
|
||||||
|
losses: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 10,
|
||||||
|
name: 'Team Kappa',
|
||||||
|
score: 1,
|
||||||
|
matchesPlayed: 5,
|
||||||
|
wins: 0,
|
||||||
|
losses: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 11,
|
||||||
|
name: 'Team Lambda',
|
||||||
|
score: 1,
|
||||||
|
matchesPlayed: 5,
|
||||||
|
wins: 0,
|
||||||
|
losses: 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rank: 12,
|
||||||
|
name: 'Team Mu',
|
||||||
|
score: 0,
|
||||||
|
matchesPlayed: 5,
|
||||||
|
wins: 0,
|
||||||
|
losses: 5
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default leaderboardData;
|
||||||
7
tournament-frontend/src/logo.svg
Normal file
7
tournament-frontend/src/logo.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
|
||||||
|
<g fill="#61DAFB">
|
||||||
|
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
|
||||||
|
<circle cx="420.9" cy="296.5" r="45.7"/>
|
||||||
|
<path d="M520.5 78.1z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
13
tournament-frontend/src/reportWebVitals.js
Normal file
13
tournament-frontend/src/reportWebVitals.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const reportWebVitals = (onPerfEntry) => {
|
||||||
|
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||||
|
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||||
|
getCLS(onPerfEntry);
|
||||||
|
getFID(onPerfEntry);
|
||||||
|
getFCP(onPerfEntry);
|
||||||
|
getLCP(onPerfEntry);
|
||||||
|
getTTFB(onPerfEntry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportWebVitals;
|
||||||
72
tournament-frontend/src/service-worker.js
Normal file
72
tournament-frontend/src/service-worker.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/* eslint-disable no-restricted-globals */
|
||||||
|
|
||||||
|
// This service worker can be customized!
|
||||||
|
// See https://developers.google.com/web/tools/workbox/modules
|
||||||
|
// for the list of available Workbox modules, or add any other
|
||||||
|
// code you'd like.
|
||||||
|
// You can also remove this file if you'd prefer not to use a
|
||||||
|
// service worker, and the Workbox build step will be skipped.
|
||||||
|
|
||||||
|
import { clientsClaim } from 'workbox-core';
|
||||||
|
import { ExpirationPlugin } from 'workbox-expiration';
|
||||||
|
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
|
||||||
|
import { registerRoute } from 'workbox-routing';
|
||||||
|
import { StaleWhileRevalidate } from 'workbox-strategies';
|
||||||
|
|
||||||
|
clientsClaim();
|
||||||
|
|
||||||
|
// Precache all of the assets generated by your build process.
|
||||||
|
// Their URLs are injected into the manifest variable below.
|
||||||
|
// This variable must be present somewhere in your service worker file,
|
||||||
|
// even if you decide not to use precaching. See https://cra.link/PWA
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|
||||||
|
// Set up App Shell-style routing, so that all navigation requests
|
||||||
|
// are fulfilled with your index.html shell. Learn more at
|
||||||
|
// https://developers.google.com/web/fundamentals/architecture/app-shell
|
||||||
|
const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
|
||||||
|
registerRoute(
|
||||||
|
// Return false to exempt requests from being fulfilled by index.html.
|
||||||
|
({ request, url }) => {
|
||||||
|
// If this isn't a navigation, skip.
|
||||||
|
if (request.mode !== 'navigate') {
|
||||||
|
return false;
|
||||||
|
} // If this is a URL that starts with /_, skip.
|
||||||
|
|
||||||
|
if (url.pathname.startsWith('/_')) {
|
||||||
|
return false;
|
||||||
|
} // If this looks like a URL for a resource, because it contains // a file extension, skip.
|
||||||
|
|
||||||
|
if (url.pathname.match(fileExtensionRegexp)) {
|
||||||
|
return false;
|
||||||
|
} // Return true to signal that we want to use the handler.
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
|
||||||
|
);
|
||||||
|
|
||||||
|
// An example runtime caching route for requests that aren't handled by the
|
||||||
|
// precache, in this case same-origin .png requests like those from in public/
|
||||||
|
registerRoute(
|
||||||
|
// Add in any other file extensions or routing criteria as needed.
|
||||||
|
({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'), // Customize this strategy as needed, e.g., by changing to CacheFirst.
|
||||||
|
new StaleWhileRevalidate({
|
||||||
|
cacheName: 'images',
|
||||||
|
plugins: [
|
||||||
|
// Ensure that once this runtime cache reaches a maximum size the
|
||||||
|
// least-recently used images are removed.
|
||||||
|
new ExpirationPlugin({ maxEntries: 50 }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// This allows the web app to trigger skipWaiting via
|
||||||
|
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Any other custom service worker logic can go here.
|
||||||
137
tournament-frontend/src/serviceWorkerRegistration.js
Normal file
137
tournament-frontend/src/serviceWorkerRegistration.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// This optional code is used to register a service worker.
|
||||||
|
// register() is not called by default.
|
||||||
|
|
||||||
|
// This lets the app load faster on subsequent visits in production, and gives
|
||||||
|
// it offline capabilities. However, it also means that developers (and users)
|
||||||
|
// will only see deployed updates on subsequent visits to a page, after all the
|
||||||
|
// existing tabs open on the page have been closed, since previously cached
|
||||||
|
// resources are updated in the background.
|
||||||
|
|
||||||
|
// To learn more about the benefits of this model and instructions on how to
|
||||||
|
// opt-in, read https://cra.link/PWA
|
||||||
|
|
||||||
|
const isLocalhost = Boolean(
|
||||||
|
window.location.hostname === 'localhost' ||
|
||||||
|
// [::1] is the IPv6 localhost address.
|
||||||
|
window.location.hostname === '[::1]' ||
|
||||||
|
// 127.0.0.0/8 are considered localhost for IPv4.
|
||||||
|
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/)
|
||||||
|
);
|
||||||
|
|
||||||
|
export function register(config) {
|
||||||
|
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||||
|
// The URL constructor is available in all browsers that support SW.
|
||||||
|
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
|
||||||
|
if (publicUrl.origin !== window.location.origin) {
|
||||||
|
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||||
|
// from what our page is served on. This might happen if a CDN is used to
|
||||||
|
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||||
|
|
||||||
|
if (isLocalhost) {
|
||||||
|
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||||
|
checkValidServiceWorker(swUrl, config);
|
||||||
|
|
||||||
|
// Add some additional logging to localhost, pointing developers to the
|
||||||
|
// service worker/PWA documentation.
|
||||||
|
navigator.serviceWorker.ready.then(() => {
|
||||||
|
console.log(
|
||||||
|
'This web app is being served cache-first by a service ' +
|
||||||
|
'worker. To learn more, visit https://cra.link/PWA'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Is not localhost. Just register service worker
|
||||||
|
registerValidSW(swUrl, config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerValidSW(swUrl, config) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register(swUrl)
|
||||||
|
.then((registration) => {
|
||||||
|
registration.onupdatefound = () => {
|
||||||
|
const installingWorker = registration.installing;
|
||||||
|
if (installingWorker == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
installingWorker.onstatechange = () => {
|
||||||
|
if (installingWorker.state === 'installed') {
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
// At this point, the updated precached content has been fetched,
|
||||||
|
// but the previous service worker will still serve the older
|
||||||
|
// content until all client tabs are closed.
|
||||||
|
console.log(
|
||||||
|
'New content is available and will be used when all ' +
|
||||||
|
'tabs for this page are closed. See https://cra.link/PWA.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute callback
|
||||||
|
if (config && config.onUpdate) {
|
||||||
|
config.onUpdate(registration);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// At this point, everything has been precached.
|
||||||
|
// It's the perfect time to display a
|
||||||
|
// "Content is cached for offline use." message.
|
||||||
|
console.log('Content is cached for offline use.');
|
||||||
|
|
||||||
|
// Execute callback
|
||||||
|
if (config && config.onSuccess) {
|
||||||
|
config.onSuccess(registration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error during service worker registration:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkValidServiceWorker(swUrl, config) {
|
||||||
|
// Check if the service worker can be found. If it can't reload the page.
|
||||||
|
fetch(swUrl, {
|
||||||
|
headers: { 'Service-Worker': 'script' },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
// Ensure service worker exists, and that we really are getting a JS file.
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (
|
||||||
|
response.status === 404 ||
|
||||||
|
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||||
|
) {
|
||||||
|
// No service worker found. Probably a different app. Reload the page.
|
||||||
|
navigator.serviceWorker.ready.then((registration) => {
|
||||||
|
registration.unregister().then(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Service worker found. Proceed as normal.
|
||||||
|
registerValidSW(swUrl, config);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log('No internet connection found. App is running in offline mode.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregister() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.ready
|
||||||
|
.then((registration) => {
|
||||||
|
registration.unregister();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
5
tournament-frontend/src/setupTests.js
Normal file
5
tournament-frontend/src/setupTests.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// allows you to do things like:
|
||||||
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
Reference in New Issue
Block a user