This commit is contained in:
2025-07-19 12:21:46 +02:00
parent 12822dfdbf
commit 2e7957d0a0
86 changed files with 25573 additions and 0 deletions

20
.cursor/argv.json Normal file
View 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"
}

View File

@@ -0,0 +1 @@
[]

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma

94
docker/.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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"

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

1102
index.js Normal file

File diff suppressed because it is too large Load Diff

2000
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View 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"
}
}

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Player" ADD COLUMN "passwordHash" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Player" ADD COLUMN "imageUrl" TEXT;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TournamentStage" ADD COLUMN "tier" INTEGER;

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "logo" TEXT;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();

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

View 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
View 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();

View 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();

View 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();

View 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();

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

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

File diff suppressed because it is too large Load Diff

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

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

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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 &#x25BC;
</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;

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

View 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();

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

View 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

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

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

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

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