Initial release: Disc Agenda frisbee tournament platform
Some checks failed
Build and Push / build (push) Failing after 8s
Some checks failed
Build and Push / build (push) Failing after 8s
Full-stack tournament management app with real-time scoring: - Go 1.26 backend with REST API and WebSocket live scoring - React 19 + Vite 8 frontend with mobile-first design - File-based JSON storage with JSONL audit logs - Multi-stage Docker build with Gitea CI/CD pipeline - Post-tournament questionnaire with spirit voting - Technical documentation and project description Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
backend/vendor
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
35
.gitea/workflows/build.yaml
Normal file
35
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Build and Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Image tag'
|
||||||
|
required: true
|
||||||
|
default: 'latest'
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u ${{ github.actor }} --password-stdin gitea.home.hrajfrisbee.cz
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
run: |
|
||||||
|
TAG=${{ github.ref_name }}
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
TAG=${{ inputs.tag }}
|
||||||
|
fi
|
||||||
|
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
|
||||||
|
docker build -t $IMAGE .
|
||||||
|
docker push $IMAGE
|
||||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Dependencies
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
backend/server
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Leftover / duplicate
|
||||||
|
frisbee-tournament/
|
||||||
|
|
||||||
|
# Data (keep structure but ignore runtime data)
|
||||||
|
data/tournaments/*/games/*_audit.jsonl
|
||||||
|
data/tournaments/*/games/*_score.json
|
||||||
|
data/tournaments/*/questionnaire_responses/*.json
|
||||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# ---- Stage 1: Build frontend ----
|
||||||
|
FROM node:22-alpine AS frontend-build
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npx vite build
|
||||||
|
|
||||||
|
# ---- Stage 2: Build Go backend ----
|
||||||
|
FROM golang:1.26-alpine AS backend-build
|
||||||
|
WORKDIR /app/backend
|
||||||
|
COPY backend/go.mod backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY backend/ ./
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server ./cmd/server
|
||||||
|
|
||||||
|
# ---- Stage 3: Runtime ----
|
||||||
|
FROM alpine:3.21
|
||||||
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=backend-build /server /app/server
|
||||||
|
COPY --from=frontend-build /app/frontend/dist /app/static
|
||||||
|
COPY data/ /app/data-seed/
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENV PORT=8080
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
|
|
||||||
|
# Copy seed data on first run if /app/data is empty (volume mount)
|
||||||
|
COPY <<'EOF' /app/entrypoint.sh
|
||||||
|
#!/bin/sh
|
||||||
|
if [ ! -f /app/data/tournaments.json ]; then
|
||||||
|
echo "Seeding initial data..."
|
||||||
|
cp -r /app/data-seed/* /app/data/
|
||||||
|
fi
|
||||||
|
exec /app/server -static /app/static -data /app/data
|
||||||
|
EOF
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
42
Makefile
Normal file
42
Makefile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
.PHONY: help dev dev-backend dev-frontend build-frontend docker image run stop logs clean tidy
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
help: ## Show this help
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | awk -F ':.*## ' '{printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
dev: ## Print instructions for local development
|
||||||
|
@echo "Start backend and frontend in separate terminals:"
|
||||||
|
@echo " make dev-backend"
|
||||||
|
@echo " make dev-frontend"
|
||||||
|
|
||||||
|
tidy: ## Run go mod tidy (requires Go 1.26+)
|
||||||
|
cd backend && go mod tidy
|
||||||
|
|
||||||
|
dev-backend: tidy ## Run backend dev server (requires Go 1.26+)
|
||||||
|
cd backend && go run ./cmd/server -data ../data -static ../frontend/dist -port 8080
|
||||||
|
|
||||||
|
dev-frontend: ## Run frontend dev server (requires Node 22+)
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
build-frontend: ## Build frontend for production
|
||||||
|
cd frontend && npm ci && npx vite build
|
||||||
|
|
||||||
|
docker: ## Build Docker images
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
image: ## Build production Docker image
|
||||||
|
docker build -t fujarna-claude:latest .
|
||||||
|
|
||||||
|
run: ## Start Docker containers
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
stop: ## Stop Docker containers
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
logs: ## Follow Docker container logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
clean: ## Remove build artifacts and node_modules
|
||||||
|
rm -rf frontend/dist frontend/node_modules
|
||||||
|
cd backend && go clean
|
||||||
164
README.md
Normal file
164
README.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# 🥏 Disc Agenda — Frisbee Tournament Platform
|
||||||
|
|
||||||
|
A self-hosted tournament management web app for ultimate frisbee, featuring live multiplayer scoring via WebSockets, a mobile-friendly questionnaire with QR codes, and a clean athletic visual design.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ Go 1.26 Backend (single binary) │
|
||||||
|
│ ├─ REST API (gorilla/mux) │
|
||||||
|
│ ├─ WebSocket hub (gorilla/websocket) │
|
||||||
|
│ ├─ SPA file server │
|
||||||
|
│ └─ File-based JSON storage │
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ React 19 + Vite 8 Frontend │
|
||||||
|
│ ├─ react-router-dom v7 (SPA) │
|
||||||
|
│ ├─ qrcode.react (QR generation) │
|
||||||
|
│ └─ Custom CSS (Bebas Neue + Barlow) │
|
||||||
|
├──────────────────────────────────────────┤
|
||||||
|
│ Data: flat JSON files + JSONL audit logs │
|
||||||
|
│ (no database required) │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Tournament hub** — home page with location, dates, teams, rules
|
||||||
|
- **Schedule** — round-grouped game schedule with status badges
|
||||||
|
- **Live scoring** — WebSocket-powered multiplayer scoreboard with +/- and SET controls
|
||||||
|
- **Audit log** — every score change persisted as JSONL per game
|
||||||
|
- **Questionnaire** — mobile-friendly survey with QR code, team selectors, spirit voting, custom questions
|
||||||
|
- **Results** — final standings table with spirit award highlight
|
||||||
|
- **Past tournaments** — archive of completed events
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Docker (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
# → http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Data persists in a Docker volume. Seed data auto-copies on first run.
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
**Prerequisites:** Go 1.26+, Node 22+
|
||||||
|
|
||||||
|
Terminal 1 — backend:
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go mod tidy
|
||||||
|
go run ./cmd/server -data ../data -static ../frontend/dist -port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal 2 — frontend (with hot reload + API proxy):
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
# → http://localhost:5173 (proxies /api and /ws to :8080)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── backend/
|
||||||
|
│ ├── cmd/server/main.go # Entry point, router, SPA handler
|
||||||
|
│ └── internal/
|
||||||
|
│ ├── handlers/handlers.go # REST + WS HTTP handlers
|
||||||
|
│ ├── models/models.go # Domain types
|
||||||
|
│ ├── storage/storage.go # File-based persistence
|
||||||
|
│ └── websocket/hub.go # Per-game WS hub + broadcast
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api.js # API client + WS factory
|
||||||
|
│ │ ├── App.jsx # Router
|
||||||
|
│ │ ├── main.jsx # Entry
|
||||||
|
│ │ ├── components/ # Header, Footer, Icons
|
||||||
|
│ │ ├── pages/ # All page components
|
||||||
|
│ │ └── styles/global.css # Full stylesheet
|
||||||
|
│ ├── vite.config.js # Dev proxy config
|
||||||
|
│ └── index.html
|
||||||
|
├── data/ # Seed data (JSON files)
|
||||||
|
│ ├── tournaments.json
|
||||||
|
│ └── tournaments/{id}/
|
||||||
|
│ ├── schedule.json
|
||||||
|
│ ├── questionnaire_config.json
|
||||||
|
│ ├── results.json
|
||||||
|
│ └── games/ # Score state + audit logs
|
||||||
|
├── Dockerfile # Multi-stage build
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── Makefile
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/tournaments` | List all tournaments |
|
||||||
|
| GET | `/api/tournaments/{id}` | Tournament details |
|
||||||
|
| GET | `/api/tournaments/{id}/schedule` | Game schedule |
|
||||||
|
| GET | `/api/tournaments/{id}/games/{gid}/score` | Current score |
|
||||||
|
| POST | `/api/tournaments/{id}/games/{gid}/score` | Update score (REST) |
|
||||||
|
| GET | `/api/tournaments/{id}/games/{gid}/audit` | Audit log |
|
||||||
|
| WS | `/ws/game/{id}/{gid}?user_id=x` | Live score WebSocket |
|
||||||
|
| GET | `/api/tournaments/{id}/questionnaire` | Questionnaire config + teams |
|
||||||
|
| POST | `/api/tournaments/{id}/questionnaire` | Submit response |
|
||||||
|
| GET | `/api/tournaments/{id}/questionnaire/results` | All responses (admin) |
|
||||||
|
| GET | `/api/tournaments/{id}/results` | Final standings |
|
||||||
|
|
||||||
|
### WebSocket Protocol
|
||||||
|
|
||||||
|
Connect: `ws://host/ws/game/{tourneyId}/{gameId}?user_id=alice`
|
||||||
|
|
||||||
|
**Send** (client → server):
|
||||||
|
```json
|
||||||
|
{"action": "increment", "team": "home"}
|
||||||
|
{"action": "decrement", "team": "away"}
|
||||||
|
{"action": "set", "team": "home", "value": 12}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Receive** (server → all clients):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "score_update",
|
||||||
|
"state": {"game_id": "g01", "home_score": 8, "away_score": 6, ...},
|
||||||
|
"audit": {"action": "increment", "team": "home", "old_home": 7, ...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All via flags or env vars:
|
||||||
|
|
||||||
|
| Flag | Env | Default | Description |
|
||||||
|
|------|-----|---------|-------------|
|
||||||
|
| `-port` | `PORT` | `8080` | Listen port |
|
||||||
|
| `-data` | `DATA_DIR` | `./data` | Data directory |
|
||||||
|
| `-static` | — | `./static` | Frontend files |
|
||||||
|
|
||||||
|
## Adding Tournament Data
|
||||||
|
|
||||||
|
Edit JSON files directly in `data/`:
|
||||||
|
|
||||||
|
- `tournaments.json` — add/edit tournament objects
|
||||||
|
- `tournaments/{id}/schedule.json` — game schedule
|
||||||
|
- `tournaments/{id}/questionnaire_config.json` — custom survey questions
|
||||||
|
- `tournaments/{id}/results.json` — final standings
|
||||||
|
|
||||||
|
No admin UI yet — data is managed via files. Future: add admin panel.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend:** Go 1.26, gorilla/mux 1.8.1, gorilla/websocket 1.5.3, rs/cors
|
||||||
|
- **Frontend:** React 19, Vite 8, react-router-dom 7, qrcode.react
|
||||||
|
- **Storage:** Flat JSON files + JSONL audit logs
|
||||||
|
- **Container:** Alpine 3.21, multi-stage Docker build
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **[Technical Documentation](docs/DOCUMENTATION.md)** — detailed API reference, data models, WebSocket protocol, deployment guides, troubleshooting
|
||||||
|
- **[Project Description](docs/PROJECT.md)** — project overview, motivation, features, design philosophy, roadmap
|
||||||
97
backend/cmd/server/main.go
Normal file
97
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/rs/cors"
|
||||||
|
|
||||||
|
"github.com/frisbee-tournament/backend/internal/handlers"
|
||||||
|
"github.com/frisbee-tournament/backend/internal/storage"
|
||||||
|
ws "github.com/frisbee-tournament/backend/internal/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
port := flag.String("port", "8080", "server port")
|
||||||
|
dataDir := flag.String("data", "./data", "data directory")
|
||||||
|
staticDir := flag.String("static", "./static", "frontend static files")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if envPort := os.Getenv("PORT"); envPort != "" {
|
||||||
|
*port = envPort
|
||||||
|
}
|
||||||
|
if envData := os.Getenv("DATA_DIR"); envData != "" {
|
||||||
|
*dataDir = envData
|
||||||
|
}
|
||||||
|
|
||||||
|
store := storage.New(*dataDir)
|
||||||
|
hubMgr := ws.NewHubManager(store)
|
||||||
|
h := handlers.New(store, hubMgr)
|
||||||
|
|
||||||
|
r := mux.NewRouter()
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
api := r.PathPrefix("/api").Subrouter()
|
||||||
|
api.HandleFunc("/tournaments", h.ListTournaments).Methods("GET")
|
||||||
|
api.HandleFunc("/tournaments/{id}", h.GetTournament).Methods("GET")
|
||||||
|
api.HandleFunc("/tournaments/{id}/schedule", h.GetSchedule).Methods("GET")
|
||||||
|
api.HandleFunc("/tournaments/{id}/games/{gid}/score", h.GetScore).Methods("GET")
|
||||||
|
api.HandleFunc("/tournaments/{id}/games/{gid}/score", h.UpdateScore).Methods("POST")
|
||||||
|
api.HandleFunc("/tournaments/{id}/games/{gid}/audit", h.GetAuditLog).Methods("GET")
|
||||||
|
api.HandleFunc("/tournaments/{id}/questionnaire", h.GetQuestionnaire).Methods("GET")
|
||||||
|
api.HandleFunc("/tournaments/{id}/questionnaire", h.SubmitQuestionnaire).Methods("POST")
|
||||||
|
api.HandleFunc("/tournaments/{id}/questionnaire/results", h.GetQuestionnaireResults).Methods("GET")
|
||||||
|
api.HandleFunc("/tournaments/{id}/results", h.GetResults).Methods("GET")
|
||||||
|
|
||||||
|
// WebSocket
|
||||||
|
r.HandleFunc("/ws/game/{id}/{gid}", h.WebSocketHandler)
|
||||||
|
r.HandleFunc("/ws/tournament/{id}", h.TournamentWebSocketHandler)
|
||||||
|
|
||||||
|
// Serve frontend (SPA fallback)
|
||||||
|
spa := spaHandler{staticPath: *staticDir, indexPath: "index.html"}
|
||||||
|
r.PathPrefix("/").Handler(spa)
|
||||||
|
|
||||||
|
c := cors.New(cors.Options{
|
||||||
|
AllowedOrigins: []string{"*"},
|
||||||
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
|
AllowedHeaders: []string{"*"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":" + *port,
|
||||||
|
Handler: c.Handler(r),
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("🥏 Frisbee Tournament server starting on :%s", *port)
|
||||||
|
log.Printf(" Data dir: %s", *dataDir)
|
||||||
|
log.Printf(" Static dir: %s", *staticDir)
|
||||||
|
if err := srv.ListenAndServe(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaHandler serves an SPA with history-mode fallback
|
||||||
|
type spaHandler struct {
|
||||||
|
staticPath string
|
||||||
|
indexPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := h.staticPath + r.URL.Path
|
||||||
|
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// SPA fallback: serve index.html
|
||||||
|
http.ServeFile(w, r, h.staticPath+"/"+h.indexPath)
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
9
backend/go.mod
Normal file
9
backend/go.mod
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
module github.com/frisbee-tournament/backend
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gorilla/mux v1.8.1
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/rs/cors v1.11.0
|
||||||
|
)
|
||||||
6
backend/go.sum
Normal file
6
backend/go.sum
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
|
||||||
|
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||||
216
backend/internal/handlers/handlers.go
Normal file
216
backend/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
gorillaWs "github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"github.com/frisbee-tournament/backend/internal/models"
|
||||||
|
"github.com/frisbee-tournament/backend/internal/storage"
|
||||||
|
ws "github.com/frisbee-tournament/backend/internal/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
store *storage.Store
|
||||||
|
hubMgr *ws.HubManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(store *storage.Store, hubMgr *ws.HubManager) *Handler {
|
||||||
|
return &Handler{store: store, hubMgr: hubMgr}
|
||||||
|
}
|
||||||
|
|
||||||
|
var upgrader = gorillaWs.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true // trust-based, no auth
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tournament endpoints ---
|
||||||
|
|
||||||
|
func (h *Handler) ListTournaments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ts, err := h.store.GetTournaments()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetTournament(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := mux.Vars(r)["id"]
|
||||||
|
t, err := h.store.GetTournament(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, 404, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Schedule ---
|
||||||
|
|
||||||
|
func (h *Handler) GetSchedule(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := mux.Vars(r)["id"]
|
||||||
|
sched, err := h.store.GetSchedule(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Merge persisted scores into schedule games
|
||||||
|
for i := range sched.Games {
|
||||||
|
g := &sched.Games[i]
|
||||||
|
state, err := h.store.GetScore(id, g.ID)
|
||||||
|
if err == nil && state.Status != "" {
|
||||||
|
g.HomeScore = state.HomeScore
|
||||||
|
g.AwayScore = state.AwayScore
|
||||||
|
g.Status = state.Status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, sched)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scoring ---
|
||||||
|
|
||||||
|
func (h *Handler) GetScore(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
state, err := h.store.GetScore(vars["id"], vars["gid"])
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateScore(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
var update models.ScoreUpdate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
|
||||||
|
writeError(w, 400, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hub := h.hubMgr.GetOrCreateHub(vars["id"], vars["gid"])
|
||||||
|
state, err := hub.HandleScoreUpdate(update)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
entries, err := h.store.GetAuditLog(vars["id"], vars["gid"])
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []models.AuditEntry{}
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WebSocket ---
|
||||||
|
|
||||||
|
func (h *Handler) WebSocketHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ws upgrade error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := r.URL.Query().Get("user_id")
|
||||||
|
if userID == "" {
|
||||||
|
userID = "anonymous"
|
||||||
|
}
|
||||||
|
hub := h.hubMgr.GetOrCreateHub(vars["id"], vars["gid"])
|
||||||
|
client := hub.RegisterClient(conn, userID)
|
||||||
|
go client.WritePump()
|
||||||
|
go client.ReadPump()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) TournamentWebSocketHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ws upgrade error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
th := h.hubMgr.GetOrCreateTournamentHub(vars["id"])
|
||||||
|
client := th.RegisterClient(conn)
|
||||||
|
go client.WritePump()
|
||||||
|
go client.ReadPumpTournament(th)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Questionnaire ---
|
||||||
|
|
||||||
|
func (h *Handler) GetQuestionnaire(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := mux.Vars(r)["id"]
|
||||||
|
cfg, err := h.store.GetQuestionnaireConfig(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, _ := h.store.GetTournament(id)
|
||||||
|
resp := map[string]any{
|
||||||
|
"config": cfg,
|
||||||
|
}
|
||||||
|
if t != nil {
|
||||||
|
resp["teams"] = t.Teams
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) SubmitQuestionnaire(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := mux.Vars(r)["id"]
|
||||||
|
var resp models.QuestionnaireResponse
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&resp); err != nil {
|
||||||
|
writeError(w, 400, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.TourneyID = id
|
||||||
|
if err := h.store.SaveQuestionnaireResponse(id, &resp); err != nil {
|
||||||
|
writeError(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 201, map[string]string{"status": "ok", "id": resp.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetQuestionnaireResults(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := mux.Vars(r)["id"]
|
||||||
|
resps, err := h.store.GetQuestionnaireResponses(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resps == nil {
|
||||||
|
resps = []models.QuestionnaireResponse{}
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, resps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Results ---
|
||||||
|
|
||||||
|
func (h *Handler) GetResults(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := mux.Vars(r)["id"]
|
||||||
|
res, err := h.store.GetResults(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, res)
|
||||||
|
}
|
||||||
118
backend/internal/models/models.go
Normal file
118
backend/internal/models/models.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type TournamentStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusUpcoming TournamentStatus = "upcoming"
|
||||||
|
StatusInProgress TournamentStatus = "in_progress"
|
||||||
|
StatusCompleted TournamentStatus = "completed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tournament struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status TournamentStatus `json:"status"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Venue string `json:"venue"`
|
||||||
|
StartDate string `json:"start_date"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Teams []Team `json:"teams"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
Rules string `json:"rules,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Team struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Logo string `json:"logo,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Game struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TourneyID string `json:"tourney_id"`
|
||||||
|
HomeTeam string `json:"home_team"`
|
||||||
|
AwayTeam string `json:"away_team"`
|
||||||
|
HomeScore int `json:"home_score"`
|
||||||
|
AwayScore int `json:"away_score"`
|
||||||
|
StartTime string `json:"start_time"`
|
||||||
|
Field string `json:"field"`
|
||||||
|
Round string `json:"round"`
|
||||||
|
Status string `json:"status"` // scheduled, live, final
|
||||||
|
}
|
||||||
|
|
||||||
|
type Schedule struct {
|
||||||
|
TourneyID string `json:"tourney_id"`
|
||||||
|
Games []Game `json:"games"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreUpdate struct {
|
||||||
|
Action string `json:"action"` // increment, decrement, set
|
||||||
|
Team string `json:"team"` // home, away
|
||||||
|
Value int `json:"value"` // used for "set"
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreState struct {
|
||||||
|
GameID string `json:"game_id"`
|
||||||
|
HomeScore int `json:"home_score"`
|
||||||
|
AwayScore int `json:"away_score"`
|
||||||
|
HomeTeam string `json:"home_team"`
|
||||||
|
AwayTeam string `json:"away_team"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditEntry struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Team string `json:"team"`
|
||||||
|
Value int `json:"value"`
|
||||||
|
OldHome int `json:"old_home"`
|
||||||
|
OldAway int `json:"old_away"`
|
||||||
|
NewHome int `json:"new_home"`
|
||||||
|
NewAway int `json:"new_away"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuestionnaireConfig struct {
|
||||||
|
TourneyID string `json:"tourney_id"`
|
||||||
|
CustomQuestions []Question `json:"custom_questions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Question struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Type string `json:"type"` // text, select, radio
|
||||||
|
Options []string `json:"options,omitempty"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuestionnaireResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TourneyID string `json:"tourney_id"`
|
||||||
|
MyTeam string `json:"my_team"`
|
||||||
|
SpiritWinner string `json:"spirit_winner"`
|
||||||
|
AttendNext bool `json:"attend_next"`
|
||||||
|
CustomAnswers map[string]string `json:"custom_answers"`
|
||||||
|
SubmittedAt time.Time `json:"submitted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinalResults struct {
|
||||||
|
TourneyID string `json:"tourney_id"`
|
||||||
|
Standings []Standing `json:"standings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Standing struct {
|
||||||
|
Position int `json:"position"`
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
TeamName string `json:"team_name"`
|
||||||
|
Wins int `json:"wins"`
|
||||||
|
Losses int `json:"losses"`
|
||||||
|
Draws int `json:"draws"`
|
||||||
|
PointsFor int `json:"points_for"`
|
||||||
|
PointsAgainst int `json:"points_against"`
|
||||||
|
SpiritScore float64 `json:"spirit_score,omitempty"`
|
||||||
|
}
|
||||||
265
backend/internal/storage/storage.go
Normal file
265
backend/internal/storage/storage.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/frisbee-tournament/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
dataDir string
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(dataDir string) *Store {
|
||||||
|
return &Store{dataDir: dataDir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ensureDir(path string) error {
|
||||||
|
return os.MkdirAll(path, 0755)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) readJSON(path string, v any) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) writeJSON(path string, v any) error {
|
||||||
|
data, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.ensureDir(filepath.Dir(path)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tournaments ---
|
||||||
|
|
||||||
|
func (s *Store) tournamentsFile() string {
|
||||||
|
return filepath.Join(s.dataDir, "tournaments.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetTournaments() ([]models.Tournament, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var ts []models.Tournament
|
||||||
|
if err := s.readJSON(s.tournamentsFile(), &ts); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetTournament(id string) (*models.Tournament, error) {
|
||||||
|
ts, err := s.GetTournaments()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, t := range ts {
|
||||||
|
if t.ID == id {
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("tournament %s not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveTournaments(ts []models.Tournament) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.writeJSON(s.tournamentsFile(), ts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Schedule ---
|
||||||
|
|
||||||
|
func (s *Store) tourneyDir(tourneyID string) string {
|
||||||
|
return filepath.Join(s.dataDir, "tournaments", tourneyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetSchedule(tourneyID string) (*models.Schedule, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var sched models.Schedule
|
||||||
|
path := filepath.Join(s.tourneyDir(tourneyID), "schedule.json")
|
||||||
|
if err := s.readJSON(path, &sched); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &models.Schedule{TourneyID: tourneyID}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &sched, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveSchedule(sched *models.Schedule) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
path := filepath.Join(s.tourneyDir(sched.TourneyID), "schedule.json")
|
||||||
|
return s.writeJSON(path, sched)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Score ---
|
||||||
|
|
||||||
|
func (s *Store) gameDir(tourneyID string) string {
|
||||||
|
return filepath.Join(s.tourneyDir(tourneyID), "games")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetScore(tourneyID, gameID string) (*models.ScoreState, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var state models.ScoreState
|
||||||
|
path := filepath.Join(s.gameDir(tourneyID), gameID+"_score.json")
|
||||||
|
if err := s.readJSON(path, &state); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &models.ScoreState{GameID: gameID}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveScore(tourneyID string, state *models.ScoreState) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
path := filepath.Join(s.gameDir(tourneyID), state.GameID+"_score.json")
|
||||||
|
return s.writeJSON(path, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AppendAuditLog(tourneyID, gameID string, entry models.AuditEntry) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
dir := s.gameDir(tourneyID)
|
||||||
|
if err := s.ensureDir(dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, gameID+"_audit.jsonl")
|
||||||
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
data, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = f.Write(append(data, '\n'))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetAuditLog(tourneyID, gameID string) ([]models.AuditEntry, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
path := filepath.Join(s.gameDir(tourneyID), gameID+"_audit.jsonl")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var entries []models.AuditEntry
|
||||||
|
for _, line := range splitLines(data) {
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var e models.AuditEntry
|
||||||
|
if err := json.Unmarshal(line, &e); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Questionnaire ---
|
||||||
|
|
||||||
|
func (s *Store) GetQuestionnaireConfig(tourneyID string) (*models.QuestionnaireConfig, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var cfg models.QuestionnaireConfig
|
||||||
|
path := filepath.Join(s.tourneyDir(tourneyID), "questionnaire_config.json")
|
||||||
|
if err := s.readJSON(path, &cfg); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &models.QuestionnaireConfig{TourneyID: tourneyID}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveQuestionnaireResponse(tourneyID string, resp *models.QuestionnaireResponse) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
resp.SubmittedAt = time.Now()
|
||||||
|
if resp.ID == "" {
|
||||||
|
resp.ID = fmt.Sprintf("resp_%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
dir := filepath.Join(s.tourneyDir(tourneyID), "questionnaire_responses")
|
||||||
|
path := filepath.Join(dir, resp.ID+".json")
|
||||||
|
return s.writeJSON(path, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetQuestionnaireResponses(tourneyID string) ([]models.QuestionnaireResponse, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
dir := filepath.Join(s.tourneyDir(tourneyID), "questionnaire_responses")
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var resps []models.QuestionnaireResponse
|
||||||
|
for _, e := range entries {
|
||||||
|
if filepath.Ext(e.Name()) != ".json" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var r models.QuestionnaireResponse
|
||||||
|
if err := s.readJSON(filepath.Join(dir, e.Name()), &r); err == nil {
|
||||||
|
resps = append(resps, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Results ---
|
||||||
|
|
||||||
|
func (s *Store) GetResults(tourneyID string) (*models.FinalResults, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var res models.FinalResults
|
||||||
|
path := filepath.Join(s.tourneyDir(tourneyID), "results.json")
|
||||||
|
if err := s.readJSON(path, &res); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &models.FinalResults{TourneyID: tourneyID}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLines(data []byte) [][]byte {
|
||||||
|
var lines [][]byte
|
||||||
|
start := 0
|
||||||
|
for i, b := range data {
|
||||||
|
if b == '\n' {
|
||||||
|
lines = append(lines, data[start:i])
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start < len(data) {
|
||||||
|
lines = append(lines, data[start:])
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
355
backend/internal/websocket/hub.go
Normal file
355
backend/internal/websocket/hub.go
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
package websocket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"github.com/frisbee-tournament/backend/internal/models"
|
||||||
|
"github.com/frisbee-tournament/backend/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
hub *Hub
|
||||||
|
conn *websocket.Conn
|
||||||
|
send chan []byte
|
||||||
|
userID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hub struct {
|
||||||
|
gameID string
|
||||||
|
tourneyID string
|
||||||
|
store *storage.Store
|
||||||
|
hubMgr *HubManager
|
||||||
|
clients map[*Client]bool
|
||||||
|
broadcast chan []byte
|
||||||
|
register chan *Client
|
||||||
|
unregister chan *Client
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// TournamentHub forwards game score updates to all schedule viewers for a tournament.
|
||||||
|
type TournamentHub struct {
|
||||||
|
tourneyID string
|
||||||
|
store *storage.Store
|
||||||
|
clients map[*Client]bool
|
||||||
|
broadcast chan []byte
|
||||||
|
register chan *Client
|
||||||
|
unregister chan *Client
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (th *TournamentHub) Run() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case client := <-th.register:
|
||||||
|
th.mu.Lock()
|
||||||
|
th.clients[client] = true
|
||||||
|
th.mu.Unlock()
|
||||||
|
case client := <-th.unregister:
|
||||||
|
th.mu.Lock()
|
||||||
|
if _, ok := th.clients[client]; ok {
|
||||||
|
delete(th.clients, client)
|
||||||
|
close(client.send)
|
||||||
|
}
|
||||||
|
th.mu.Unlock()
|
||||||
|
case message := <-th.broadcast:
|
||||||
|
th.mu.RLock()
|
||||||
|
for client := range th.clients {
|
||||||
|
select {
|
||||||
|
case client.send <- message:
|
||||||
|
default:
|
||||||
|
close(client.send)
|
||||||
|
delete(th.clients, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
th.mu.RUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (th *TournamentHub) RegisterClient(conn *websocket.Conn) *Client {
|
||||||
|
c := &Client{
|
||||||
|
conn: conn,
|
||||||
|
send: make(chan []byte, 256),
|
||||||
|
}
|
||||||
|
th.register <- c
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadPumpTournament keeps the connection alive (reads pongs) but ignores incoming messages.
|
||||||
|
func (c *Client) ReadPumpTournament(th *TournamentHub) {
|
||||||
|
defer func() {
|
||||||
|
th.unregister <- c
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
c.conn.SetReadLimit(512)
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
c.conn.SetPongHandler(func(string) error {
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
for {
|
||||||
|
if _, _, err := c.conn.ReadMessage(); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HubManager struct {
|
||||||
|
hubs map[string]*Hub
|
||||||
|
tournamentHubs map[string]*TournamentHub
|
||||||
|
mu sync.RWMutex
|
||||||
|
store *storage.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHubManager(store *storage.Store) *HubManager {
|
||||||
|
return &HubManager{
|
||||||
|
hubs: make(map[string]*Hub),
|
||||||
|
tournamentHubs: make(map[string]*TournamentHub),
|
||||||
|
store: store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *HubManager) GetOrCreateHub(tourneyID, gameID string) *Hub {
|
||||||
|
key := tourneyID + "/" + gameID
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if h, ok := m.hubs[key]; ok {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
h := &Hub{
|
||||||
|
gameID: gameID,
|
||||||
|
tourneyID: tourneyID,
|
||||||
|
store: m.store,
|
||||||
|
hubMgr: m,
|
||||||
|
clients: make(map[*Client]bool),
|
||||||
|
broadcast: make(chan []byte, 256),
|
||||||
|
register: make(chan *Client),
|
||||||
|
unregister: make(chan *Client),
|
||||||
|
}
|
||||||
|
m.hubs[key] = h
|
||||||
|
go h.Run()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *HubManager) GetOrCreateTournamentHub(tourneyID string) *TournamentHub {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if th, ok := m.tournamentHubs[tourneyID]; ok {
|
||||||
|
return th
|
||||||
|
}
|
||||||
|
th := &TournamentHub{
|
||||||
|
tourneyID: tourneyID,
|
||||||
|
store: m.store,
|
||||||
|
clients: make(map[*Client]bool),
|
||||||
|
broadcast: make(chan []byte, 256),
|
||||||
|
register: make(chan *Client),
|
||||||
|
unregister: make(chan *Client),
|
||||||
|
}
|
||||||
|
m.tournamentHubs[tourneyID] = th
|
||||||
|
go th.Run()
|
||||||
|
return th
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastToTournament forwards a game update to the tournament hub if it exists.
|
||||||
|
func (m *HubManager) BroadcastToTournament(tourneyID string, msg []byte) {
|
||||||
|
m.mu.RLock()
|
||||||
|
th, ok := m.tournamentHubs[tourneyID]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
select {
|
||||||
|
case th.broadcast <- msg:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Run() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case client := <-h.register:
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[client] = true
|
||||||
|
h.mu.Unlock()
|
||||||
|
// Send current score to new client
|
||||||
|
state, err := h.store.GetScore(h.tourneyID, h.gameID)
|
||||||
|
if err == nil {
|
||||||
|
data, _ := json.Marshal(map[string]any{
|
||||||
|
"type": "score_state",
|
||||||
|
"state": state,
|
||||||
|
})
|
||||||
|
client.send <- data
|
||||||
|
}
|
||||||
|
case client := <-h.unregister:
|
||||||
|
h.mu.Lock()
|
||||||
|
if _, ok := h.clients[client]; ok {
|
||||||
|
delete(h.clients, client)
|
||||||
|
close(client.send)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
case message := <-h.broadcast:
|
||||||
|
h.mu.RLock()
|
||||||
|
for client := range h.clients {
|
||||||
|
select {
|
||||||
|
case client.send <- message:
|
||||||
|
default:
|
||||||
|
close(client.send)
|
||||||
|
delete(h.clients, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) HandleScoreUpdate(update models.ScoreUpdate) (*models.ScoreState, error) {
|
||||||
|
state, err := h.store.GetScore(h.tourneyID, h.gameID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
oldHome := state.HomeScore
|
||||||
|
oldAway := state.AwayScore
|
||||||
|
|
||||||
|
switch update.Action {
|
||||||
|
case "increment":
|
||||||
|
if state.Status == "final" {
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
if update.Team == "home" {
|
||||||
|
state.HomeScore++
|
||||||
|
} else {
|
||||||
|
state.AwayScore++
|
||||||
|
}
|
||||||
|
case "decrement":
|
||||||
|
if state.Status == "final" {
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
if update.Team == "home" && state.HomeScore > 0 {
|
||||||
|
state.HomeScore--
|
||||||
|
} else if update.Team == "away" && state.AwayScore > 0 {
|
||||||
|
state.AwayScore--
|
||||||
|
}
|
||||||
|
case "set":
|
||||||
|
if state.Status == "final" {
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
if update.Team == "home" {
|
||||||
|
state.HomeScore = update.Value
|
||||||
|
} else {
|
||||||
|
state.AwayScore = update.Value
|
||||||
|
}
|
||||||
|
case "set_status":
|
||||||
|
// update.Team carries the target status: scheduled, live, final
|
||||||
|
if update.Team == "scheduled" || update.Team == "live" || update.Team == "final" {
|
||||||
|
state.Status = update.Team
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-transition: scheduled → live when score becomes non-zero
|
||||||
|
if update.Action != "set_status" && state.Status == "scheduled" && (state.HomeScore > 0 || state.AwayScore > 0) {
|
||||||
|
state.Status = "live"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.SaveScore(h.tourneyID, state); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write audit log
|
||||||
|
entry := models.AuditEntry{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Action: update.Action,
|
||||||
|
Team: update.Team,
|
||||||
|
Value: update.Value,
|
||||||
|
OldHome: oldHome,
|
||||||
|
OldAway: oldAway,
|
||||||
|
NewHome: state.HomeScore,
|
||||||
|
NewAway: state.AwayScore,
|
||||||
|
UserID: update.UserID,
|
||||||
|
}
|
||||||
|
if err := h.store.AppendAuditLog(h.tourneyID, h.gameID, entry); err != nil {
|
||||||
|
log.Printf("audit log error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast to all clients on this game hub
|
||||||
|
msg, _ := json.Marshal(map[string]any{
|
||||||
|
"type": "score_update",
|
||||||
|
"state": state,
|
||||||
|
"audit": entry,
|
||||||
|
})
|
||||||
|
h.broadcast <- msg
|
||||||
|
|
||||||
|
// Also forward to tournament hub for schedule viewers
|
||||||
|
h.hubMgr.BroadcastToTournament(h.tourneyID, msg)
|
||||||
|
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) RegisterClient(conn *websocket.Conn, userID string) *Client {
|
||||||
|
c := &Client{
|
||||||
|
hub: h,
|
||||||
|
conn: conn,
|
||||||
|
send: make(chan []byte, 256),
|
||||||
|
userID: userID,
|
||||||
|
}
|
||||||
|
h.register <- c
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ReadPump() {
|
||||||
|
defer func() {
|
||||||
|
c.hub.unregister <- c
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
c.conn.SetReadLimit(4096)
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
c.conn.SetPongHandler(func(string) error {
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
for {
|
||||||
|
_, message, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var update models.ScoreUpdate
|
||||||
|
if err := json.Unmarshal(message, &update); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
update.Timestamp = time.Now()
|
||||||
|
update.UserID = c.userID
|
||||||
|
if _, err := c.hub.HandleScoreUpdate(update); err != nil {
|
||||||
|
log.Printf("score update error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) WritePump() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case message, ok := <-c.send:
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if !ok {
|
||||||
|
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
data/tournaments.json
Normal file
25
data/tournaments.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "fujarna-14-3-2026",
|
||||||
|
"name": "Fujarna - 14.3.2026",
|
||||||
|
"status": "upcoming",
|
||||||
|
"location": "Praha, Czech Republic",
|
||||||
|
"venue": "Víceúčelové sportoviště \"Kotlářka\"",
|
||||||
|
"start_date": "2026-03-14",
|
||||||
|
"end_date": "2026-03-14",
|
||||||
|
"description": "Fujarna turnaj v ultimate frisbee. 10 týmů ve 2 skupinách, crossover pavouk.",
|
||||||
|
"teams": [
|
||||||
|
{"id": "fuj-1", "name": "FUJ 1"},
|
||||||
|
{"id": "kocicaci", "name": "Kočičáci"},
|
||||||
|
{"id": "spitalska", "name": "Špitálská"},
|
||||||
|
{"id": "sunset", "name": "Sunset"},
|
||||||
|
{"id": "hoko-coko-diskyto", "name": "Hoko-Čoko Diskýto"},
|
||||||
|
{"id": "fuj-2", "name": "FUJ 2"},
|
||||||
|
{"id": "bjorn", "name": "Björn"},
|
||||||
|
{"id": "gybot", "name": "GyBot"},
|
||||||
|
{"id": "poletime", "name": "Poletíme"},
|
||||||
|
{"id": "kachny", "name": "Kachny"}
|
||||||
|
],
|
||||||
|
"rules": "Hra 20 min. Bez pauzy. Bez TO. Remíza není možná."
|
||||||
|
}
|
||||||
|
]
|
||||||
25
data/tournaments/fujarna-14-3-2026/questionnaire_config.json
Normal file
25
data/tournaments/fujarna-14-3-2026/questionnaire_config.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"tourney_id": "fujarna-14-3-2026",
|
||||||
|
"custom_questions": [
|
||||||
|
{
|
||||||
|
"id": "q_food",
|
||||||
|
"text": "How would you rate the food at the tournament?",
|
||||||
|
"type": "select",
|
||||||
|
"options": ["Excellent", "Good", "Average", "Poor"],
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "q_fields",
|
||||||
|
"text": "How were the playing fields?",
|
||||||
|
"type": "select",
|
||||||
|
"options": ["Great condition", "Acceptable", "Needs improvement"],
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "q_feedback",
|
||||||
|
"text": "Any other feedback or suggestions?",
|
||||||
|
"type": "text",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
30
data/tournaments/fujarna-14-3-2026/schedule.json
Normal file
30
data/tournaments/fujarna-14-3-2026/schedule.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"tourney_id": "fujarna-14-3-2026",
|
||||||
|
"games": [
|
||||||
|
{"id": "g01", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 1", "away_team": "Kočičáci", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T08:30:00", "field": "Field 1", "round": "Pool A - Round 1", "status": "scheduled"},
|
||||||
|
{"id": "g02", "tourney_id": "fujarna-14-3-2026", "home_team": "Špitálská", "away_team": "Sunset", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T08:50:00", "field": "Field 1", "round": "Pool A - Round 1", "status": "scheduled"},
|
||||||
|
{"id": "g03", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 1", "away_team": "Hoko-Čoko Diskýto", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T09:10:00", "field": "Field 1", "round": "Pool A - Round 2", "status": "scheduled"},
|
||||||
|
{"id": "g04", "tourney_id": "fujarna-14-3-2026", "home_team": "Špitálská", "away_team": "Kočičáci", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T09:30:00", "field": "Field 1", "round": "Pool A - Round 2", "status": "scheduled"},
|
||||||
|
{"id": "g05", "tourney_id": "fujarna-14-3-2026", "home_team": "Sunset", "away_team": "Hoko-Čoko Diskýto", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T09:50:00", "field": "Field 1", "round": "Pool A - Round 3", "status": "scheduled"},
|
||||||
|
{"id": "g06", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 1", "away_team": "Špitálská", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T10:10:00", "field": "Field 1", "round": "Pool A - Round 3", "status": "scheduled"},
|
||||||
|
{"id": "g07", "tourney_id": "fujarna-14-3-2026", "home_team": "Kočičáci", "away_team": "Sunset", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T10:30:00", "field": "Field 1", "round": "Pool A - Round 4", "status": "scheduled"},
|
||||||
|
{"id": "g08", "tourney_id": "fujarna-14-3-2026", "home_team": "Špitálská", "away_team": "Hoko-Čoko Diskýto", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T10:50:00", "field": "Field 1", "round": "Pool A - Round 4", "status": "scheduled"},
|
||||||
|
{"id": "g09", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 1", "away_team": "Sunset", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T11:10:00", "field": "Field 1", "round": "Pool A - Round 5", "status": "scheduled"},
|
||||||
|
{"id": "g10", "tourney_id": "fujarna-14-3-2026", "home_team": "Kočičáci", "away_team": "Hoko-Čoko Diskýto", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T11:30:00", "field": "Field 1", "round": "Pool A - Round 5", "status": "scheduled"},
|
||||||
|
{"id": "g11", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 2", "away_team": "Björn", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T11:50:00", "field": "Field 1", "round": "Pool B - Round 1", "status": "scheduled"},
|
||||||
|
{"id": "g12", "tourney_id": "fujarna-14-3-2026", "home_team": "GyBot", "away_team": "Poletíme", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T12:10:00", "field": "Field 1", "round": "Pool B - Round 1", "status": "scheduled"},
|
||||||
|
{"id": "g13", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 2", "away_team": "Kachny", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T12:30:00", "field": "Field 1", "round": "Pool B - Round 2", "status": "scheduled"},
|
||||||
|
{"id": "g14", "tourney_id": "fujarna-14-3-2026", "home_team": "Björn", "away_team": "GyBot", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T12:50:00", "field": "Field 1", "round": "Pool B - Round 2", "status": "scheduled"},
|
||||||
|
{"id": "g15", "tourney_id": "fujarna-14-3-2026", "home_team": "Poletíme", "away_team": "Kachny", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T13:10:00", "field": "Field 1", "round": "Pool B - Round 3", "status": "scheduled"},
|
||||||
|
{"id": "g16", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 2", "away_team": "GyBot", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T13:30:00", "field": "Field 1", "round": "Pool B - Round 3", "status": "scheduled"},
|
||||||
|
{"id": "g17", "tourney_id": "fujarna-14-3-2026", "home_team": "Björn", "away_team": "Poletíme", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T13:50:00", "field": "Field 1", "round": "Pool B - Round 4", "status": "scheduled"},
|
||||||
|
{"id": "g18", "tourney_id": "fujarna-14-3-2026", "home_team": "GyBot", "away_team": "Kachny", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T14:10:00", "field": "Field 1", "round": "Pool B - Round 4", "status": "scheduled"},
|
||||||
|
{"id": "g19", "tourney_id": "fujarna-14-3-2026", "home_team": "FUJ 2", "away_team": "Poletíme", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T14:30:00", "field": "Field 1", "round": "Pool B - Round 5", "status": "scheduled"},
|
||||||
|
{"id": "g20", "tourney_id": "fujarna-14-3-2026", "home_team": "Björn", "away_team": "Kachny", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T14:50:00", "field": "Field 1", "round": "Pool B - Round 5", "status": "scheduled"},
|
||||||
|
{"id": "p5", "tourney_id": "fujarna-14-3-2026", "home_team": "5A", "away_team": "5B", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T15:10:00", "field": "Field 1", "round": "5th Place", "status": "scheduled"},
|
||||||
|
{"id": "p4", "tourney_id": "fujarna-14-3-2026", "home_team": "4A", "away_team": "4B", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T15:40:00", "field": "Field 1", "round": "4th Place", "status": "scheduled"},
|
||||||
|
{"id": "p3", "tourney_id": "fujarna-14-3-2026", "home_team": "3A", "away_team": "3B", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T16:10:00", "field": "Field 1", "round": "3rd Place", "status": "scheduled"},
|
||||||
|
{"id": "p2", "tourney_id": "fujarna-14-3-2026", "home_team": "2A", "away_team": "2B", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T16:40:00", "field": "Field 1", "round": "2nd Place", "status": "scheduled"},
|
||||||
|
{"id": "final", "tourney_id": "fujarna-14-3-2026", "home_team": "1A", "away_team": "1B", "home_score": 0, "away_score": 0, "start_time": "2026-03-14T17:10:00", "field": "Field 1", "round": "Grand Final", "status": "scheduled"}
|
||||||
|
]
|
||||||
|
}
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
frisbee:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- tournament-data:/app/data
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
- TZ=Europe/Prague
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tournament-data:
|
||||||
943
docs/DOCUMENTATION.md
Normal file
943
docs/DOCUMENTATION.md
Normal file
@@ -0,0 +1,943 @@
|
|||||||
|
# Disc Agenda -- Technical Documentation
|
||||||
|
|
||||||
|
Detailed developer reference for the Disc Agenda frisbee tournament platform. For quick start and project overview, see the [README](../README.md). For project motivation and feature descriptions, see the [Project Description](PROJECT.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Architecture Overview](#1-architecture-overview)
|
||||||
|
2. [Data Model Reference](#2-data-model-reference)
|
||||||
|
3. [API Reference](#3-api-reference)
|
||||||
|
4. [WebSocket Protocol](#4-websocket-protocol)
|
||||||
|
5. [Storage Format](#5-storage-format)
|
||||||
|
6. [How-To Guides](#6-how-to-guides)
|
||||||
|
7. [Development Setup](#7-development-setup)
|
||||||
|
8. [Docker Deployment](#8-docker-deployment)
|
||||||
|
9. [CI/CD Pipeline](#9-cicd-pipeline)
|
||||||
|
10. [Frontend Architecture](#10-frontend-architecture)
|
||||||
|
11. [Troubleshooting](#11-troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture Overview
|
||||||
|
|
||||||
|
Disc Agenda is a single-binary Go server that serves both a REST API and a compiled React SPA. There is no separate frontend server in production.
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser
|
||||||
|
│
|
||||||
|
├── GET /tournament/... ──────► SPA Handler ──► index.html (React Router takes over)
|
||||||
|
├── GET/POST /api/... ──────► REST Handlers ──► Storage (JSON files)
|
||||||
|
└── WS /ws/... ──────► WebSocket Hubs ──► Broadcast to all clients
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
File System (data/)
|
||||||
|
├── tournaments.json
|
||||||
|
└── tournaments/{id}/
|
||||||
|
├── schedule.json
|
||||||
|
├── games/{gid}_score.json
|
||||||
|
├── games/{gid}_audit.jsonl
|
||||||
|
├── questionnaire_config.json
|
||||||
|
└── questionnaire_responses/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key design decisions:**
|
||||||
|
|
||||||
|
- **No database** -- flat JSON files are sufficient for small tournament scale (10-20 teams). Human-readable and easily backed up.
|
||||||
|
- **No authentication** -- trust-based model for community use. Anyone with the URL can update scores.
|
||||||
|
- **SPA fallback** -- any unmatched request serves `index.html`, letting React Router handle client-side routing.
|
||||||
|
- **WebSocket hubs** -- per-game hubs manage scoring clients; a separate tournament hub broadcasts all game updates to schedule viewers.
|
||||||
|
|
||||||
|
### Source files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `backend/cmd/server/main.go` | Entry point, router setup, SPA handler |
|
||||||
|
| `backend/internal/handlers/handlers.go` | REST + WebSocket HTTP handlers |
|
||||||
|
| `backend/internal/models/models.go` | Domain types |
|
||||||
|
| `backend/internal/storage/storage.go` | File-based persistence layer |
|
||||||
|
| `backend/internal/websocket/hub.go` | WebSocket hub management and scoring logic |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Data Model Reference
|
||||||
|
|
||||||
|
All types are defined in `backend/internal/models/models.go`.
|
||||||
|
|
||||||
|
### 2.1 Tournament
|
||||||
|
|
||||||
|
| Field | JSON | Type | Description |
|
||||||
|
|-------|------|------|-------------|
|
||||||
|
| ID | `id` | string | URL-safe unique identifier (e.g., `fujarna-14-3-2026`) |
|
||||||
|
| Name | `name` | string | Display name |
|
||||||
|
| Status | `status` | string | `upcoming`, `in_progress`, or `completed` |
|
||||||
|
| Location | `location` | string | City/country |
|
||||||
|
| Venue | `venue` | string | Specific venue name |
|
||||||
|
| StartDate | `start_date` | string | `YYYY-MM-DD` format |
|
||||||
|
| EndDate | `end_date` | string | `YYYY-MM-DD` format |
|
||||||
|
| Description | `description` | string | Short description |
|
||||||
|
| Teams | `teams` | []Team | Array of participating teams |
|
||||||
|
| ImageURL | `image_url` | string | Optional banner image URL |
|
||||||
|
| Rules | `rules` | string | Optional rules text |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "fujarna-14-3-2026",
|
||||||
|
"name": "Fujarna - 14.3.2026",
|
||||||
|
"status": "upcoming",
|
||||||
|
"location": "Praha, Czech Republic",
|
||||||
|
"venue": "Víceúčelové sportoviště \"Kotlářka\"",
|
||||||
|
"start_date": "2026-03-14",
|
||||||
|
"end_date": "2026-03-14",
|
||||||
|
"description": "Fujarna turnaj v ultimate frisbee. 10 týmů ve 2 skupinách, crossover pavouk.",
|
||||||
|
"teams": [
|
||||||
|
{"id": "fuj-1", "name": "FUJ 1"},
|
||||||
|
{"id": "kocicaci", "name": "Kočičáci"}
|
||||||
|
],
|
||||||
|
"rules": "Hra 20 min. Bez pauzy. Bez TO. Remíza není možná."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Team
|
||||||
|
|
||||||
|
| Field | JSON | Type | Description |
|
||||||
|
|-------|------|------|-------------|
|
||||||
|
| ID | `id` | string | URL-safe identifier |
|
||||||
|
| Name | `name` | string | Display name |
|
||||||
|
| Logo | `logo` | string | Optional logo URL |
|
||||||
|
|
||||||
|
### 2.3 Game
|
||||||
|
|
||||||
|
| Field | JSON | Type | Description |
|
||||||
|
|-------|------|------|-------------|
|
||||||
|
| ID | `id` | string | Game identifier (e.g., `g01`, `p5`, `final`) |
|
||||||
|
| TourneyID | `tourney_id` | string | Parent tournament ID |
|
||||||
|
| HomeTeam | `home_team` | string | Home team display name (not ID) |
|
||||||
|
| AwayTeam | `away_team` | string | Away team display name (not ID) |
|
||||||
|
| HomeScore | `home_score` | int | Current home score |
|
||||||
|
| AwayScore | `away_score` | int | Current away score |
|
||||||
|
| StartTime | `start_time` | string | ISO 8601 datetime |
|
||||||
|
| Field | `field` | string | Field name/number |
|
||||||
|
| Round | `round` | string | Round name (e.g., `Pool A - Round 1`, `Grand Final`) |
|
||||||
|
| Status | `status` | string | `scheduled`, `live`, or `final` |
|
||||||
|
|
||||||
|
Note: `home_team` and `away_team` store display names rather than team IDs. For bracket/placement games, placeholders like `1A`, `3B` are used until teams are determined.
|
||||||
|
|
||||||
|
### 2.4 Schedule
|
||||||
|
|
||||||
|
| Field | JSON | Type | Description |
|
||||||
|
|-------|------|------|-------------|
|
||||||
|
| TourneyID | `tourney_id` | string | Tournament ID |
|
||||||
|
| Games | `games` | []Game | Array of all games |
|
||||||
|
|
||||||
|
### 2.5 ScoreState
|
||||||
|
|
||||||
|
Represents the current state of a game's score. Returned by score endpoints and WebSocket messages.
|
||||||
|
|
||||||
|
| Field | JSON | Type | Description |
|
||||||
|
|-------|------|------|-------------|
|
||||||
|
| GameID | `game_id` | string | Game identifier |
|
||||||
|
| HomeScore | `home_score` | int | Current home score |
|
||||||
|
| AwayScore | `away_score` | int | Current away score |
|
||||||
|
| HomeTeam | `home_team` | string | Home team name |
|
||||||
|
| AwayTeam | `away_team` | string | Away team name |
|
||||||
|
| Status | `status` | string | `scheduled`, `live`, or `final` |
|
||||||
|
|
||||||
|
### 2.6 ScoreUpdate
|
||||||
|
|
||||||
|
Sent by clients to modify a game's score.
|
||||||
|
|
||||||
|
| Field | JSON | Type | Description |
|
||||||
|
|-------|------|------|-------------|
|
||||||
|
| Action | `action` | string | `increment`, `decrement`, `set`, or `set_status` |
|
||||||
|
| Team | `team` | string | `home` or `away` (for score actions); `scheduled`, `live`, or `final` (for `set_status`) |
|
||||||
|
| Value | `value` | int | Target value (used only with `set` action) |
|
||||||
|
| Timestamp | `timestamp` | time | Set server-side |
|
||||||
|
| UserID | `user_id` | string | Set server-side from WebSocket query param |
|
||||||
|
|
||||||
|
### 2.7 AuditEntry
|
||||||
|
|
||||||
|
One entry per score change, appended to the audit log.
|
||||||
|
|
||||||
|
| Field | JSON | Type | Description |
|
||||||
|
|-------|------|------|-------------|
|
||||||
|
| Timestamp | `timestamp` | time | When the change occurred |
|
||||||
|
| Action | `action` | string | The action performed |
|
||||||
|
| Team | `team` | string | Which team/status was affected |
|
||||||
|
| Value | `value` | int | Value for `set` actions |
|
||||||
|
| OldHome | `old_home` | int | Home score before change |
|
||||||
|
| OldAway | `old_away` | int | Away score before change |
|
||||||
|
| NewHome | `new_home` | int | Home score after change |
|
||||||
|
| NewAway | `new_away` | int | Away score after change |
|
||||||
|
| UserID | `user_id` | string | Who made the change |
|
||||||
|
|
||||||
|
### 2.8 QuestionnaireConfig
|
||||||
|
|
||||||
|
| Field | JSON | Type | Description |
|
||||||
|
|-------|------|------|-------------|
|
||||||
|
| TourneyID | `tourney_id` | string | Tournament ID |
|
||||||
|
| CustomQuestions | `custom_questions` | []Question | Custom survey questions |
|
||||||
|
|
||||||
|
### 2.9 Question
|
||||||
|
|
||||||
|
| Field | JSON | Type | Description |
|
||||||
|
|-------|------|------|-------------|
|
||||||
|
| ID | `id` | string | Unique question identifier |
|
||||||
|
| Text | `text` | string | Question text displayed to user |
|
||||||
|
| Type | `type` | string | `text`, `select`, or `radio` |
|
||||||
|
| Options | `options` | []string | Choices (for `select` and `radio` types) |
|
||||||
|
| Required | `required` | bool | Whether answer is mandatory |
|
||||||
|
|
||||||
|
### 2.10 QuestionnaireResponse
|
||||||
|
|
||||||
|
| Field | JSON | Type | Description |
|
||||||
|
|-------|------|------|-------------|
|
||||||
|
| ID | `id` | string | Auto-generated: `resp_{unix_nano}` |
|
||||||
|
| TourneyID | `tourney_id` | string | Set server-side from URL |
|
||||||
|
| MyTeam | `my_team` | string | Respondent's team name |
|
||||||
|
| SpiritWinner | `spirit_winner` | string | Team voted for best spirit |
|
||||||
|
| AttendNext | `attend_next` | bool | Will attend next tournament |
|
||||||
|
| CustomAnswers | `custom_answers` | map[string]string | Key: question ID, value: answer |
|
||||||
|
| SubmittedAt | `submitted_at` | time | Set server-side |
|
||||||
|
|
||||||
|
### 2.11 FinalResults / Standing
|
||||||
|
|
||||||
|
**FinalResults:**
|
||||||
|
|
||||||
|
| Field | JSON | Type | Description |
|
||||||
|
|-------|------|------|-------------|
|
||||||
|
| TourneyID | `tourney_id` | string | Tournament ID |
|
||||||
|
| Standings | `standings` | []Standing | Ordered standings |
|
||||||
|
|
||||||
|
**Standing:**
|
||||||
|
|
||||||
|
| Field | JSON | Type | Description |
|
||||||
|
|-------|------|------|-------------|
|
||||||
|
| Position | `position` | int | Final placement (1 = winner) |
|
||||||
|
| TeamID | `team_id` | string | Team identifier |
|
||||||
|
| TeamName | `team_name` | string | Team display name |
|
||||||
|
| Wins | `wins` | int | Number of wins |
|
||||||
|
| Losses | `losses` | int | Number of losses |
|
||||||
|
| Draws | `draws` | int | Number of draws |
|
||||||
|
| PointsFor | `points_for` | int | Total points scored |
|
||||||
|
| PointsAgainst | `points_against` | int | Total points conceded |
|
||||||
|
| SpiritScore | `spirit_score` | float64 | Optional spirit rating |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API Reference
|
||||||
|
|
||||||
|
All endpoints return JSON. Error responses use the format `{"error": "message"}`.
|
||||||
|
|
||||||
|
### 3.1 GET /api/tournaments
|
||||||
|
|
||||||
|
Returns all tournaments.
|
||||||
|
|
||||||
|
**Response** `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "fujarna-14-3-2026",
|
||||||
|
"name": "Fujarna - 14.3.2026",
|
||||||
|
"status": "upcoming",
|
||||||
|
"location": "Praha, Czech Republic",
|
||||||
|
"venue": "Víceúčelové sportoviště \"Kotlářka\"",
|
||||||
|
"start_date": "2026-03-14",
|
||||||
|
"end_date": "2026-03-14",
|
||||||
|
"description": "Fujarna turnaj v ultimate frisbee...",
|
||||||
|
"teams": [{"id": "fuj-1", "name": "FUJ 1"}, ...],
|
||||||
|
"rules": "Hra 20 min. Bez pauzy. Bez TO. Remíza není možná."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 GET /api/tournaments/{id}
|
||||||
|
|
||||||
|
Returns a single tournament.
|
||||||
|
|
||||||
|
**Response** `200`: Tournament object
|
||||||
|
**Response** `404`: `{"error": "tournament fujarna-xyz not found"}`
|
||||||
|
|
||||||
|
### 3.3 GET /api/tournaments/{id}/schedule
|
||||||
|
|
||||||
|
Returns the game schedule with current scores merged in. The handler reads each game's persisted score state and overwrites the schedule's default `home_score`, `away_score`, and `status` fields.
|
||||||
|
|
||||||
|
**Response** `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tourney_id": "fujarna-14-3-2026",
|
||||||
|
"games": [
|
||||||
|
{
|
||||||
|
"id": "g01",
|
||||||
|
"tourney_id": "fujarna-14-3-2026",
|
||||||
|
"home_team": "FUJ 1",
|
||||||
|
"away_team": "Kočičáci",
|
||||||
|
"home_score": 8,
|
||||||
|
"away_score": 6,
|
||||||
|
"start_time": "2026-03-14T08:30:00",
|
||||||
|
"field": "Field 1",
|
||||||
|
"round": "Pool A - Round 1",
|
||||||
|
"status": "live"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 GET /api/tournaments/{id}/games/{gid}/score
|
||||||
|
|
||||||
|
Returns current score state for a specific game.
|
||||||
|
|
||||||
|
**Response** `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"game_id": "g01",
|
||||||
|
"home_score": 8,
|
||||||
|
"away_score": 6,
|
||||||
|
"home_team": "FUJ 1",
|
||||||
|
"away_team": "Kočičáci",
|
||||||
|
"status": "live"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If no score file exists yet, returns zeroed state with empty status.
|
||||||
|
|
||||||
|
### 3.5 POST /api/tournaments/{id}/games/{gid}/score
|
||||||
|
|
||||||
|
Updates a game's score via REST (alternative to WebSocket). The update is also broadcast to all WebSocket clients on the game and tournament hubs.
|
||||||
|
|
||||||
|
**Request body examples:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action": "increment", "team": "home"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action": "decrement", "team": "away"}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action": "set", "team": "home", "value": 12}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"action": "set_status", "team": "final"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `200`: Updated ScoreState object
|
||||||
|
**Response** `400`: `{"error": "invalid request body"}`
|
||||||
|
|
||||||
|
**Behavior notes:**
|
||||||
|
- `increment`, `decrement`, `set` are no-ops when game status is `final`
|
||||||
|
- `decrement` will not go below 0
|
||||||
|
- Auto-transitions from `scheduled` to `live` when score becomes non-zero
|
||||||
|
|
||||||
|
### 3.6 GET /api/tournaments/{id}/games/{gid}/audit
|
||||||
|
|
||||||
|
Returns the complete audit trail for a game.
|
||||||
|
|
||||||
|
**Response** `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-03-14T09:15:32.123Z",
|
||||||
|
"action": "increment",
|
||||||
|
"team": "home",
|
||||||
|
"value": 0,
|
||||||
|
"old_home": 7,
|
||||||
|
"old_away": 6,
|
||||||
|
"new_home": 8,
|
||||||
|
"new_away": 6,
|
||||||
|
"user_id": "user_abc123"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `[]` (empty array) if no audit entries exist.
|
||||||
|
|
||||||
|
### 3.7 GET /api/tournaments/{id}/questionnaire
|
||||||
|
|
||||||
|
Returns the questionnaire config and team list for the tournament.
|
||||||
|
|
||||||
|
**Response** `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"tourney_id": "fujarna-14-3-2026",
|
||||||
|
"custom_questions": [
|
||||||
|
{
|
||||||
|
"id": "q_food",
|
||||||
|
"text": "How would you rate the food at the tournament?",
|
||||||
|
"type": "select",
|
||||||
|
"options": ["Excellent", "Good", "Average", "Poor"],
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "q_feedback",
|
||||||
|
"text": "Any other feedback or suggestions?",
|
||||||
|
"type": "text",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"teams": [
|
||||||
|
{"id": "fuj-1", "name": "FUJ 1"},
|
||||||
|
{"id": "kocicaci", "name": "Kočičáci"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.8 POST /api/tournaments/{id}/questionnaire
|
||||||
|
|
||||||
|
Submits a questionnaire response. The server auto-generates `id` (as `resp_{unix_nano}`) and `submitted_at`.
|
||||||
|
|
||||||
|
**Request body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"my_team": "FUJ 1",
|
||||||
|
"spirit_winner": "Kočičáci",
|
||||||
|
"attend_next": true,
|
||||||
|
"custom_answers": {
|
||||||
|
"q_food": "Good",
|
||||||
|
"q_fields": "Acceptable",
|
||||||
|
"q_feedback": "Great tournament!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** `201`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status": "ok", "id": "resp_1710423600000000000"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.9 GET /api/tournaments/{id}/questionnaire/results
|
||||||
|
|
||||||
|
Returns all submitted questionnaire responses. Intended for organizer/admin use.
|
||||||
|
|
||||||
|
**Response** `200`: Array of QuestionnaireResponse objects. Returns `[]` if none exist.
|
||||||
|
|
||||||
|
### 3.10 GET /api/tournaments/{id}/results
|
||||||
|
|
||||||
|
Returns final standings.
|
||||||
|
|
||||||
|
**Response** `200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tourney_id": "fujarna-14-3-2026",
|
||||||
|
"standings": [
|
||||||
|
{
|
||||||
|
"position": 1,
|
||||||
|
"team_id": "fuj-1",
|
||||||
|
"team_name": "FUJ 1",
|
||||||
|
"wins": 6,
|
||||||
|
"losses": 1,
|
||||||
|
"draws": 0,
|
||||||
|
"points_for": 85,
|
||||||
|
"points_against": 52,
|
||||||
|
"spirit_score": 4.2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. WebSocket Protocol
|
||||||
|
|
||||||
|
### 4.1 Game WebSocket
|
||||||
|
|
||||||
|
**Endpoint:** `ws://host/ws/game/{tourneyId}/{gameId}?user_id=alice`
|
||||||
|
|
||||||
|
The `user_id` query parameter identifies the scorer. If omitted, defaults to `"anonymous"`.
|
||||||
|
|
||||||
|
**Connection lifecycle:**
|
||||||
|
|
||||||
|
1. Client connects
|
||||||
|
2. Server immediately sends current score state:
|
||||||
|
```json
|
||||||
|
{"type": "score_state", "state": {"game_id": "g01", "home_score": 8, ...}}
|
||||||
|
```
|
||||||
|
3. Client sends score updates:
|
||||||
|
```json
|
||||||
|
{"action": "increment", "team": "home"}
|
||||||
|
{"action": "decrement", "team": "away"}
|
||||||
|
{"action": "set", "team": "home", "value": 12}
|
||||||
|
{"action": "set_status", "team": "final"}
|
||||||
|
```
|
||||||
|
4. Server broadcasts to ALL connected clients (including sender):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "score_update",
|
||||||
|
"state": {"game_id": "g01", "home_score": 9, "away_score": 6, ...},
|
||||||
|
"audit": {"action": "increment", "team": "home", "old_home": 8, "new_home": 9, ...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Score update rules:**
|
||||||
|
|
||||||
|
- `increment` / `decrement` / `set` are ignored when `status == "final"`
|
||||||
|
- `decrement` will not go below 0
|
||||||
|
- Auto-transition: `scheduled` -> `live` when any score becomes non-zero
|
||||||
|
- `set_status` accepts `scheduled`, `live`, or `final` as the `team` field value
|
||||||
|
|
||||||
|
**Keepalive:**
|
||||||
|
|
||||||
|
- Server sends ping every 30 seconds
|
||||||
|
- Read deadline: 60 seconds (client must respond with pong)
|
||||||
|
- Write deadline: 10 seconds per message
|
||||||
|
- Read limit: 4096 bytes
|
||||||
|
|
||||||
|
### 4.2 Tournament WebSocket
|
||||||
|
|
||||||
|
**Endpoint:** `ws://host/ws/tournament/{tourneyId}`
|
||||||
|
|
||||||
|
Read-only connection for schedule viewers. Receives all `score_update` broadcasts from every game hub in the tournament.
|
||||||
|
|
||||||
|
- Client sends nothing (the read pump only processes pong frames)
|
||||||
|
- Receives the same `score_update` messages as game WebSocket clients
|
||||||
|
- Read limit: 512 bytes
|
||||||
|
- Used by the SchedulePage for live score updates across all games
|
||||||
|
|
||||||
|
### 4.3 Hub Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
HubManager
|
||||||
|
├── hubs (map: "{tourneyId}/{gameId}" -> Hub)
|
||||||
|
│ ├── Hub "fujarna.../g01"
|
||||||
|
│ │ ├── clients[] (connected scorers)
|
||||||
|
│ │ ├── register/unregister channels
|
||||||
|
│ │ └── broadcast channel
|
||||||
|
│ └── Hub "fujarna.../g02" ...
|
||||||
|
└── tournamentHubs (map: "{tourneyId}" -> TournamentHub)
|
||||||
|
└── TournamentHub "fujarna..."
|
||||||
|
├── clients[] (schedule viewers)
|
||||||
|
└── broadcast channel
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Lazy creation:** hubs are created on first connection via `GetOrCreateHub` / `GetOrCreateTournamentHub`
|
||||||
|
- **Game -> Tournament forwarding:** after every score update, the game hub calls `BroadcastToTournament()` to push the same message to all tournament hub clients
|
||||||
|
- **On client registration:** the game hub immediately sends the current `score_state` to the new client
|
||||||
|
- **Thread safety:** `sync.RWMutex` protects hub maps and client maps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Storage Format
|
||||||
|
|
||||||
|
### 5.1 Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
data/
|
||||||
|
├── tournaments.json # Array of all tournaments
|
||||||
|
└── tournaments/
|
||||||
|
└── {tournament-id}/
|
||||||
|
├── schedule.json # Game schedule
|
||||||
|
├── questionnaire_config.json # Survey configuration
|
||||||
|
├── results.json # Final standings
|
||||||
|
├── games/
|
||||||
|
│ ├── {game-id}_score.json # Current score state
|
||||||
|
│ └── {game-id}_audit.jsonl # Append-only audit log
|
||||||
|
└── questionnaire_responses/
|
||||||
|
└── resp_{unix_nano}.json # One file per response
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 File Formats
|
||||||
|
|
||||||
|
**Score state** (`{gid}_score.json`): Pretty-printed JSON of ScoreState:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"game_id": "g01",
|
||||||
|
"home_score": 8,
|
||||||
|
"away_score": 6,
|
||||||
|
"home_team": "FUJ 1",
|
||||||
|
"away_team": "Kočičáci",
|
||||||
|
"status": "live"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Audit log** (`{gid}_audit.jsonl`): One JSON object per line, appended atomically:
|
||||||
|
|
||||||
|
```
|
||||||
|
{"timestamp":"2026-03-14T09:00:15Z","action":"increment","team":"home","value":0,"old_home":0,"old_away":0,"new_home":1,"new_away":0,"user_id":"user_abc"}
|
||||||
|
{"timestamp":"2026-03-14T09:01:22Z","action":"increment","team":"away","value":0,"old_home":1,"old_away":0,"new_home":1,"new_away":1,"user_id":"user_def"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Concurrency Model
|
||||||
|
|
||||||
|
- A single `sync.RWMutex` on the `Store` protects all file reads and writes
|
||||||
|
- Audit log uses `os.O_APPEND` for atomic line appends
|
||||||
|
- Directories are auto-created on write via `os.MkdirAll`
|
||||||
|
- All JSON files use `json.MarshalIndent` with 2-space indentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. How-To Guides
|
||||||
|
|
||||||
|
### 6.1 Adding a New Tournament
|
||||||
|
|
||||||
|
1. **Edit `data/tournaments.json`** -- add a new tournament object to the array:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my-tournament-2026",
|
||||||
|
"name": "My Tournament 2026",
|
||||||
|
"status": "upcoming",
|
||||||
|
"location": "City, Country",
|
||||||
|
"venue": "Venue Name",
|
||||||
|
"start_date": "2026-06-15",
|
||||||
|
"end_date": "2026-06-15",
|
||||||
|
"description": "Description of the tournament.",
|
||||||
|
"teams": [
|
||||||
|
{"id": "team-a", "name": "Team A"},
|
||||||
|
{"id": "team-b", "name": "Team B"},
|
||||||
|
{"id": "team-c", "name": "Team C"},
|
||||||
|
{"id": "team-d", "name": "Team D"}
|
||||||
|
],
|
||||||
|
"rules": "Game rules here."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create the tournament directory:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data/tournaments/my-tournament-2026/games
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Create `data/tournaments/my-tournament-2026/schedule.json`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tourney_id": "my-tournament-2026",
|
||||||
|
"games": [
|
||||||
|
{
|
||||||
|
"id": "g01",
|
||||||
|
"tourney_id": "my-tournament-2026",
|
||||||
|
"home_team": "Team A",
|
||||||
|
"away_team": "Team B",
|
||||||
|
"home_score": 0,
|
||||||
|
"away_score": 0,
|
||||||
|
"start_time": "2026-06-15T09:00:00",
|
||||||
|
"field": "Field 1",
|
||||||
|
"round": "Pool - Round 1",
|
||||||
|
"status": "scheduled"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Restart the server** (the server reads files on each request, but a restart ensures clean state for WebSocket hubs).
|
||||||
|
|
||||||
|
### 6.2 Configuring the Questionnaire
|
||||||
|
|
||||||
|
Create `data/tournaments/{id}/questionnaire_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tourney_id": "my-tournament-2026",
|
||||||
|
"custom_questions": [
|
||||||
|
{
|
||||||
|
"id": "q_food",
|
||||||
|
"text": "How would you rate the food?",
|
||||||
|
"type": "select",
|
||||||
|
"options": ["Excellent", "Good", "Average", "Poor"],
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "q_feedback",
|
||||||
|
"text": "Any suggestions?",
|
||||||
|
"type": "text",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Built-in fields** (always present in the UI, not configured here):
|
||||||
|
- **My Team** -- select from tournament team list
|
||||||
|
- **Spirit Winner** -- select from tournament team list
|
||||||
|
- **Attend Next Tournament** -- checkbox
|
||||||
|
|
||||||
|
**Custom question types:**
|
||||||
|
- `select` -- dropdown with predefined options
|
||||||
|
- `radio` -- radio button group
|
||||||
|
- `text` -- free-text input
|
||||||
|
|
||||||
|
### 6.3 Adding Results After Tournament
|
||||||
|
|
||||||
|
Create `data/tournaments/{id}/results.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tourney_id": "my-tournament-2026",
|
||||||
|
"standings": [
|
||||||
|
{
|
||||||
|
"position": 1,
|
||||||
|
"team_id": "team-a",
|
||||||
|
"team_name": "Team A",
|
||||||
|
"wins": 5,
|
||||||
|
"losses": 0,
|
||||||
|
"draws": 0,
|
||||||
|
"points_for": 75,
|
||||||
|
"points_against": 30,
|
||||||
|
"spirit_score": 4.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"position": 2,
|
||||||
|
"team_id": "team-b",
|
||||||
|
"team_name": "Team B",
|
||||||
|
"wins": 3,
|
||||||
|
"losses": 2,
|
||||||
|
"draws": 0,
|
||||||
|
"points_for": 55,
|
||||||
|
"points_against": 42
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `spirit_score` is optional. The results page highlights the team with the highest spirit score.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Development Setup
|
||||||
|
|
||||||
|
### 7.1 Prerequisites
|
||||||
|
|
||||||
|
- **Go 1.26+** -- backend
|
||||||
|
- **Node 22+** -- frontend build and dev server
|
||||||
|
- **npm** -- comes with Node
|
||||||
|
|
||||||
|
### 7.2 Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go mod tidy
|
||||||
|
go run ./cmd/server -data ../data -static ../frontend/dist -port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Server flags:
|
||||||
|
|
||||||
|
| Flag | Env Var | Default | Description |
|
||||||
|
|------|---------|---------|-------------|
|
||||||
|
| `-port` | `PORT` | `8080` | HTTP listen port |
|
||||||
|
| `-data` | `DATA_DIR` | `./data` | Data directory path |
|
||||||
|
| `-static` | -- | `./static` | Frontend static files directory |
|
||||||
|
|
||||||
|
The SPA handler serves `index.html` for any request that doesn't match a static file, `/api/`, or `/ws/` path. This enables React Router's client-side routing.
|
||||||
|
|
||||||
|
CORS is configured to allow all origins (`*`), all common methods, and all headers.
|
||||||
|
|
||||||
|
### 7.3 Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite dev server runs on `http://localhost:5173` and proxies:
|
||||||
|
- `/api/*` -> `http://localhost:8080`
|
||||||
|
- `/ws/*` -> `ws://localhost:8080`
|
||||||
|
|
||||||
|
This allows hot-reload development while the Go backend handles API and WebSocket connections.
|
||||||
|
|
||||||
|
### 7.4 Makefile Targets
|
||||||
|
|
||||||
|
| Target | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `make help` | Show all available targets |
|
||||||
|
| `make dev` | Print local development instructions |
|
||||||
|
| `make tidy` | Run `go mod tidy` |
|
||||||
|
| `make dev-backend` | Run Go backend dev server |
|
||||||
|
| `make dev-frontend` | Run Vite frontend dev server |
|
||||||
|
| `make build-frontend` | Production frontend build (`npm ci && npx vite build`) |
|
||||||
|
| `make docker` | Build Docker images via docker compose |
|
||||||
|
| `make image` | Build standalone Docker image |
|
||||||
|
| `make run` | Start Docker containers (detached) |
|
||||||
|
| `make stop` | Stop Docker containers |
|
||||||
|
| `make logs` | Follow Docker container logs |
|
||||||
|
| `make clean` | Remove `frontend/dist`, `node_modules`, and Go build cache |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Docker Deployment
|
||||||
|
|
||||||
|
### 8.1 Multi-Stage Build
|
||||||
|
|
||||||
|
The Dockerfile uses three stages for a minimal production image:
|
||||||
|
|
||||||
|
**Stage 1 -- Frontend build** (Node 22 Alpine):
|
||||||
|
- Installs npm dependencies (`npm ci`)
|
||||||
|
- Builds React app with Vite (`npx vite build`)
|
||||||
|
- Output: `frontend/dist/`
|
||||||
|
|
||||||
|
**Stage 2 -- Backend build** (Go 1.26 Alpine):
|
||||||
|
- Downloads Go modules
|
||||||
|
- Builds static binary with `CGO_ENABLED=0` and stripped debug symbols (`-ldflags="-s -w"`)
|
||||||
|
- Output: `/server` binary
|
||||||
|
|
||||||
|
**Stage 3 -- Runtime** (Alpine 3.21):
|
||||||
|
- Copies server binary, built frontend, and seed data
|
||||||
|
- Installs only `ca-certificates` and `tzdata`
|
||||||
|
- Exposes port 8080
|
||||||
|
|
||||||
|
### 8.2 Entrypoint & Data Seeding
|
||||||
|
|
||||||
|
The entrypoint script seeds initial data on first run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
if [ ! -f /app/data/tournaments.json ]; then
|
||||||
|
echo "Seeding initial data..."
|
||||||
|
cp -r /app/data-seed/* /app/data/
|
||||||
|
fi
|
||||||
|
exec /app/server -static /app/static -data /app/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Seed data is baked into the image at `/app/data-seed/`. On first run (when the volume is empty), it's copied to `/app/data/`. On subsequent runs, existing data in the volume is preserved.
|
||||||
|
|
||||||
|
### 8.3 Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
frisbee:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- tournament-data:/app/data
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
- TZ=Europe/Prague
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
tournament-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Volume `tournament-data`** persists all game scores, audit logs, and questionnaire responses across container restarts
|
||||||
|
- **Timezone** is set to `Europe/Prague` for correct timestamps in audit logs
|
||||||
|
- **Restart policy** ensures the server comes back after crashes or host reboots
|
||||||
|
|
||||||
|
### 8.4 Production Considerations
|
||||||
|
|
||||||
|
- **No TLS termination** -- use a reverse proxy (nginx, Caddy, Traefik) for HTTPS
|
||||||
|
- **No authentication** -- anyone with the URL can update scores; consider network-level access control
|
||||||
|
- **WebSocket origin check** is disabled (`CheckOrigin` returns `true` for all requests)
|
||||||
|
- **Data backup** -- the Docker volume can be backed up by copying its contents or using `docker cp`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. CI/CD Pipeline
|
||||||
|
|
||||||
|
Defined in `.gitea/workflows/build.yaml`.
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- Manual dispatch (`workflow_dispatch`) with optional `tag` input
|
||||||
|
- Tag push (`push: tags`)
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Checkout repository
|
||||||
|
2. Login to Gitea container registry at `gitea.home.hrajfrisbee.cz`
|
||||||
|
3. Build Docker image using the multi-stage Dockerfile
|
||||||
|
4. Push to registry as `gitea.home.hrajfrisbee.cz/{owner}/{repo}:{tag}`
|
||||||
|
|
||||||
|
**Tag resolution:** uses the manual input tag, or falls back to `github.ref_name` (the pushed tag or branch name).
|
||||||
|
|
||||||
|
**Secrets required:**
|
||||||
|
- `REGISTRY_TOKEN` -- authentication token for the Gitea container registry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Frontend Architecture
|
||||||
|
|
||||||
|
### 10.1 Routes
|
||||||
|
|
||||||
|
| Path | Component | Description |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| `/` | -- | Redirects to `/tournament/fujarna-14-3-2026` |
|
||||||
|
| `/tournament/:id` | TournamentPage | Tournament detail with info cards |
|
||||||
|
| `/tournament/:id/schedule` | SchedulePage | Live game schedule |
|
||||||
|
| `/tournament/:id/game/:gid` | GamePage | Live scoreboard |
|
||||||
|
| `/tournament/:id/questionnaire` | QuestionnairePage | Post-tournament survey |
|
||||||
|
| `/tournament/:id/results` | ResultsPage | Final standings |
|
||||||
|
| `/past` | PastPage | Archive of completed tournaments |
|
||||||
|
|
||||||
|
### 10.2 API Client (`frontend/src/api.js`)
|
||||||
|
|
||||||
|
- `fetchJSON(path)` / `postJSON(path, body)` -- wrappers around `fetch` with error handling
|
||||||
|
- All API functions are thin wrappers: `getTournaments()`, `getTournament(id)`, `getSchedule(id)`, etc.
|
||||||
|
- `createGameWebSocket(tourneyId, gameId, userId)` -- creates WebSocket with auto protocol detection (`ws:` or `wss:` based on page protocol)
|
||||||
|
- `createTournamentWebSocket(tourneyId)` -- read-only WebSocket for schedule updates
|
||||||
|
|
||||||
|
### 10.3 Key Components
|
||||||
|
|
||||||
|
**GamePage** -- live scoreboard:
|
||||||
|
- Connects via game WebSocket with a session-scoped `user_id` (random, stored in `sessionStorage`)
|
||||||
|
- +/- buttons send `increment`/`decrement` actions
|
||||||
|
- SET button opens a modal for direct value entry
|
||||||
|
- Displays audit log (expandable)
|
||||||
|
- Generates QR code linking to the questionnaire page
|
||||||
|
- Auto-reconnects on WebSocket disconnect (3-second delay)
|
||||||
|
|
||||||
|
**SchedulePage** -- tournament schedule:
|
||||||
|
- Connects via tournament WebSocket for live updates across all games
|
||||||
|
- Groups games by round (Pool A, Pool B, placement bracket)
|
||||||
|
- Shows status badges and live scores
|
||||||
|
- Auto-reconnects on disconnect
|
||||||
|
|
||||||
|
**QuestionnairePage** -- post-tournament survey:
|
||||||
|
- Loads questionnaire config and team list from API
|
||||||
|
- Renders built-in fields (my team, spirit winner, attend next) and custom questions
|
||||||
|
- Czech-language UI labels
|
||||||
|
- Shows success message after submission
|
||||||
|
|
||||||
|
**ResultsPage** -- final standings:
|
||||||
|
- Renders standings table with W/L/D, points for/against, point differential
|
||||||
|
- Medal emojis for top 3 positions
|
||||||
|
- Highlights team with highest `spirit_score`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Troubleshooting
|
||||||
|
|
||||||
|
**WebSocket not connecting:**
|
||||||
|
- Ensure the backend is running and accessible
|
||||||
|
- Check that your reverse proxy forwards WebSocket connections (upgrade headers)
|
||||||
|
- Verify the `/ws/` path prefix is not blocked
|
||||||
|
|
||||||
|
**Scores not persisting:**
|
||||||
|
- Check `data/` directory permissions (needs read/write)
|
||||||
|
- Verify the Docker volume is mounted correctly
|
||||||
|
- Check server logs for write errors
|
||||||
|
|
||||||
|
**Frontend shows 404 on page refresh:**
|
||||||
|
- The SPA handler should serve `index.html` for unknown paths
|
||||||
|
- Ensure `-static` flag points to the directory containing `index.html`
|
||||||
|
|
||||||
|
**Schedule shows stale scores:**
|
||||||
|
- The schedule endpoint merges from individual score files on every request
|
||||||
|
- Check that WebSocket connection is active (green indicator in browser dev tools)
|
||||||
|
- Try refreshing the page to re-establish the tournament WebSocket
|
||||||
|
|
||||||
|
**Audit log growing large:**
|
||||||
|
- Audit files are JSONL, one line per score change
|
||||||
|
- Safe to truncate or archive old `.jsonl` files when not needed
|
||||||
|
|
||||||
|
**Docker volume data reset:**
|
||||||
|
- Data only seeds when `tournaments.json` is missing from the volume
|
||||||
|
- To force a reseed: `docker compose down -v` (removes volumes), then `docker compose up`
|
||||||
|
|
||||||
|
**CORS errors in development:**
|
||||||
|
- The backend allows all origins (`*`)
|
||||||
|
- In dev mode, use the Vite dev server (port 5173) which proxies API calls to the backend
|
||||||
220
docs/PROJECT.md
Normal file
220
docs/PROJECT.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# Disc Agenda -- Project Description
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
Disc Agenda is a self-hosted web application for managing ultimate frisbee tournaments. It provides a single URL where players and spectators can view the tournament schedule, follow live scores in real-time, fill out post-tournament questionnaires, and browse final standings -- all from their phones, with no app install required.
|
||||||
|
|
||||||
|
Built for the Czech ultimate frisbee community, the platform is currently deployed for the **Fujarna** tournament series in Prague.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem Statement
|
||||||
|
|
||||||
|
Small and casual frisbee tournaments typically lack proper digital infrastructure. Organizers rely on a patchwork of tools:
|
||||||
|
|
||||||
|
- **Google Sheets** for schedules (not real-time, clunky on mobile)
|
||||||
|
- **WhatsApp/Telegram groups** for score announcements (noisy, easy to miss)
|
||||||
|
- **Paper scorecards** at the field (single point of truth, hard to read from a distance)
|
||||||
|
- **Google Forms** for post-tournament feedback (disconnected from the event)
|
||||||
|
|
||||||
|
This creates friction for everyone: players don't know when their next game starts or what the current score is in a parallel game, spectators can't follow along remotely, and organizers spend time relaying information manually.
|
||||||
|
|
||||||
|
**Disc Agenda solves this** by providing a single, mobile-friendly web app where:
|
||||||
|
- The schedule is always visible and up-to-date
|
||||||
|
- Any participant can update scores in real-time
|
||||||
|
- Scores appear live on everyone's screen via WebSocket
|
||||||
|
- Post-tournament feedback is collected in context
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Key Features
|
||||||
|
|
||||||
|
### 3.1 Tournament Hub
|
||||||
|
|
||||||
|
The landing page for each tournament displays all essential information at a glance: event name, date, location, venue, participating teams, and rules. Clear navigation links guide users to the schedule, questionnaire, and results.
|
||||||
|
|
||||||
|
### 3.2 Live Scoreboard
|
||||||
|
|
||||||
|
The core feature. Each game has its own scoring page with large, touch-friendly controls:
|
||||||
|
|
||||||
|
- **+/- buttons** for quick score increments
|
||||||
|
- **SET button** for direct value entry (correcting mistakes)
|
||||||
|
- **Real-time sync** -- multiple people can score simultaneously, and all changes appear instantly on every connected device via WebSocket
|
||||||
|
- **Audit trail** -- every score change is logged with timestamp and user identity, viewable on the game page
|
||||||
|
- **QR code** -- each game page includes a QR code linking to the post-tournament questionnaire, making it easy to share
|
||||||
|
|
||||||
|
Game states transition automatically: `Scheduled` -> `Live` (when the first point is scored) -> `Final` (set manually). Once a game is marked as final, the score is locked.
|
||||||
|
|
||||||
|
### 3.3 Live Schedule
|
||||||
|
|
||||||
|
The schedule page groups all games by round (Pool A, Pool B, placement bracket) with start times and field assignments. Scores update in real-time across all games -- viewers don't need to open individual game pages to see current scores.
|
||||||
|
|
||||||
|
### 3.4 Post-Tournament Questionnaire
|
||||||
|
|
||||||
|
A mobile-friendly survey form with:
|
||||||
|
|
||||||
|
- **Spirit of the Game voting** -- select which team showed the best sportsmanship
|
||||||
|
- **Team selector** -- identify your own team
|
||||||
|
- **Custom questions** -- organizer-configurable (food rating, field conditions, free-text feedback)
|
||||||
|
- **Attend next tournament** -- gauge interest for future events
|
||||||
|
|
||||||
|
Responses are stored per tournament and can be reviewed by organizers.
|
||||||
|
|
||||||
|
### 3.5 Results & Standings
|
||||||
|
|
||||||
|
A final standings table showing each team's position, win/loss/draw record, points scored and conceded, point differential, and spirit score. The team with the highest spirit score receives a highlighted award.
|
||||||
|
|
||||||
|
### 3.6 Past Tournaments Archive
|
||||||
|
|
||||||
|
An archive page listing completed tournaments, allowing the community to look back at previous events and their results.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. How It Works
|
||||||
|
|
||||||
|
### For the Organizer
|
||||||
|
|
||||||
|
1. **Prepare data** -- create JSON files defining the tournament (teams, schedule, questionnaire)
|
||||||
|
2. **Deploy** -- `docker compose up` on any server (a Raspberry Pi works fine)
|
||||||
|
3. **Share the URL** -- send it to teams before or on tournament day
|
||||||
|
|
||||||
|
### On Tournament Day
|
||||||
|
|
||||||
|
1. Players open the URL on their phones
|
||||||
|
2. Designated scorers (or anyone) navigate to a game page and tap +/- to update scores
|
||||||
|
3. All connected viewers see score changes instantly
|
||||||
|
4. The schedule page reflects live scores across all games in real-time
|
||||||
|
5. Between games, players can check upcoming matches and current standings
|
||||||
|
|
||||||
|
### After the Tournament
|
||||||
|
|
||||||
|
1. Organizer creates `results.json` with final standings
|
||||||
|
2. Players fill out the questionnaire (linked via QR codes on game pages)
|
||||||
|
3. Organizer reviews feedback via the questionnaire results API
|
||||||
|
4. Tournament status is set to `completed` and appears in the archive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Technology Stack & Rationale
|
||||||
|
|
||||||
|
### Go Backend
|
||||||
|
|
||||||
|
**Why Go:** A single statically-compiled binary with excellent concurrency primitives. Perfect for WebSocket-heavy applications where multiple clients need real-time updates. Minimal runtime dependencies -- the compiled binary runs on bare Alpine Linux.
|
||||||
|
|
||||||
|
**Libraries:** Only three external dependencies:
|
||||||
|
- `gorilla/mux` -- HTTP router
|
||||||
|
- `gorilla/websocket` -- WebSocket protocol
|
||||||
|
- `rs/cors` -- Cross-origin request handling
|
||||||
|
|
||||||
|
### React + Vite Frontend
|
||||||
|
|
||||||
|
**Why React:** The component model maps naturally to the interactive scoreboard UI -- each game card, score counter, and status badge is a composable piece. React's state management handles the bidirectional WebSocket data flow cleanly.
|
||||||
|
|
||||||
|
**Why Vite:** Modern build tooling with fast hot-reload during development. Produces optimized static assets for production.
|
||||||
|
|
||||||
|
**Minimal dependencies:** No state management library (React's built-in state is sufficient), no UI component framework (custom CSS provides an athletic visual identity), just `react-router-dom` for routing and `qrcode.react` for QR generation.
|
||||||
|
|
||||||
|
### File-Based Storage
|
||||||
|
|
||||||
|
**Why no database:** The application serves small tournaments (10-20 teams, 20-30 games). JSON files provide:
|
||||||
|
- Zero operational overhead (no database to install, configure, or maintain)
|
||||||
|
- Human-readable data (edit tournament data with any text editor)
|
||||||
|
- Easy backup (copy a directory)
|
||||||
|
- Portability (works anywhere a filesystem exists)
|
||||||
|
|
||||||
|
Audit logs use JSONL (JSON Lines) format for append-only writes that survive crashes without corruption.
|
||||||
|
|
||||||
|
**Trade-off:** Not suitable for high-concurrency write scenarios or multi-server deployments. Single-server, single-process is the intended deployment model.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
**Why Docker:** Reproducible deployment in a single command. The multi-stage build produces a minimal image (~15 MB) containing just the Go binary, frontend assets, and Alpine Linux. A named volume persists tournament data across container updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Go Backend (single binary) │
|
||||||
|
│ ├── REST API → JSON file storage │
|
||||||
|
│ ├── WebSocket hubs → real-time broadcasting │
|
||||||
|
│ └── SPA handler → serves React frontend │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ React Frontend (compiled static assets) │
|
||||||
|
│ ├── Schedule view ← tournament WebSocket │
|
||||||
|
│ ├── Scoreboard ← game WebSocket │
|
||||||
|
│ └── Forms → REST API │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ Data (flat JSON files) │
|
||||||
|
│ ├── Tournament config, schedule, results │
|
||||||
|
│ ├── Per-game score state │
|
||||||
|
│ └── JSONL audit logs │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The entire application runs as a single process. The Go server handles HTTP requests, WebSocket connections, and file I/O. The React frontend is compiled to static files and served by the same Go process. No separate web server, no reverse proxy (though one is recommended for TLS), no database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Current State
|
||||||
|
|
||||||
|
### First Tournament: Fujarna - 14.3.2026
|
||||||
|
|
||||||
|
- **Location:** Prague, Czech Republic
|
||||||
|
- **Venue:** Kotlarka multi-purpose sports facility
|
||||||
|
- **Teams:** 10 teams in 2 pools of 5
|
||||||
|
- Pool A: FUJ 1, Kocicaci, Spitalska, Sunset, Hoko-Coko Diskyto
|
||||||
|
- Pool B: FUJ 2, Bjorn, GyBot, Poletime, Kachny
|
||||||
|
- **Format:** Full round-robin within pools (10 games per pool), followed by crossover placement bracket (5th place through Grand Final)
|
||||||
|
- **Schedule:** 25 games total, 08:30 - 17:10, 20-minute games
|
||||||
|
- **Rules:** 20 min per game, no breaks, no timeouts, no draws allowed
|
||||||
|
|
||||||
|
### Application Status
|
||||||
|
|
||||||
|
- All core features are implemented and functional
|
||||||
|
- UI is in Czech language throughout
|
||||||
|
- Ready for production deployment via Docker
|
||||||
|
- CI/CD pipeline configured for Gitea container registry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Design Philosophy
|
||||||
|
|
||||||
|
**Simple over complex.** No admin UI, no user accounts, no database. Tournament data is managed by editing JSON files. This keeps the codebase small, the deployment trivial, and the maintenance burden near zero.
|
||||||
|
|
||||||
|
**Mobile-first.** Every page is designed for phone screens -- the primary use case is field-side scoring and schedule checking. Large touch targets, readable typography, responsive layout.
|
||||||
|
|
||||||
|
**Real-time everywhere.** WebSocket connections power both the scoreboard and the schedule. If a score changes, everyone sees it within milliseconds. No polling, no manual refresh.
|
||||||
|
|
||||||
|
**Self-hosted.** Full control over data and infrastructure. No external service dependencies, no API keys, no subscriptions. Runs on any machine that can run Docker -- a home server, a VPS, or a Raspberry Pi.
|
||||||
|
|
||||||
|
**Trust-based.** In a community of ~50 frisbee players, authentication is overhead without benefit. Anyone can update scores, and the audit log provides accountability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Future Plans
|
||||||
|
|
||||||
|
Potential directions for the platform:
|
||||||
|
|
||||||
|
- **Admin panel** -- web UI for managing tournaments, teams, and schedules (replacing manual JSON editing)
|
||||||
|
- **Authentication** -- optional login for score-keeping to prevent accidental edits
|
||||||
|
- **Automatic standings** -- calculate standings from game results instead of manual `results.json`
|
||||||
|
- **Photo gallery** -- tournament photos integrated into the event page
|
||||||
|
- **Push notifications** -- alerts for game start, game end, or close scores
|
||||||
|
- **Player registration** -- team sign-up and roster management
|
||||||
|
- **Statistics** -- game history, scoring trends, spirit score analytics
|
||||||
|
- **Multi-language support** -- currently Czech-only
|
||||||
|
- **Multiple fields** -- field assignment management for larger tournaments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Contributing
|
||||||
|
|
||||||
|
The project is organized for easy contribution:
|
||||||
|
|
||||||
|
- **Backend** (`backend/`): Standard Go project structure with `cmd/` and `internal/` packages
|
||||||
|
- **Frontend** (`frontend/`): React SPA with page-based component organization
|
||||||
|
- **Data** (`data/`): JSON seed data that can be modified for testing
|
||||||
|
|
||||||
|
For detailed technical reference, API documentation, and development setup instructions, see the [Technical Documentation](DOCUMENTATION.md). For quick start and project structure, see the [README](../README.md).
|
||||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Frisbee Tournament</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Barlow:wght@400;500;600;700&family=Barlow+Condensed:wght@600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1043
frontend/package-lock.json
generated
Normal file
1043
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^6.0.0",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
61
frontend/public/hero-illustration.svg
Normal file
61
frontend/public/hero-illustration.svg
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" fill="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#0d2818"/>
|
||||||
|
<stop offset="100%" stop-color="#166534"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="grass" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#22c55e"/>
|
||||||
|
<stop offset="100%" stop-color="#16a34a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="disc-grad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#f97316"/>
|
||||||
|
<stop offset="100%" stop-color="#fb923c"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="800" height="400" fill="url(#sky)"/>
|
||||||
|
|
||||||
|
<!-- Grass field -->
|
||||||
|
<ellipse cx="400" cy="420" rx="500" ry="120" fill="url(#grass)" opacity="0.3"/>
|
||||||
|
|
||||||
|
<!-- Field lines -->
|
||||||
|
<line x1="100" y1="350" x2="700" y2="350" stroke="#22c55e" stroke-width="1" opacity="0.2"/>
|
||||||
|
<line x1="150" y1="330" x2="650" y2="330" stroke="#22c55e" stroke-width="1" opacity="0.15"/>
|
||||||
|
|
||||||
|
<!-- Player silhouette - layout dive -->
|
||||||
|
<g transform="translate(320, 180) rotate(-15)">
|
||||||
|
<!-- Body -->
|
||||||
|
<ellipse cx="0" cy="0" rx="18" ry="35" fill="white" opacity="0.9"/>
|
||||||
|
<!-- Head -->
|
||||||
|
<circle cx="-5" cy="-42" r="14" fill="white" opacity="0.9"/>
|
||||||
|
<!-- Extended arm (throwing) -->
|
||||||
|
<line x1="15" y1="-15" x2="65" y2="-35" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.9"/>
|
||||||
|
<!-- Other arm -->
|
||||||
|
<line x1="-15" y1="-10" x2="-55" y2="10" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.9"/>
|
||||||
|
<!-- Legs (diving) -->
|
||||||
|
<line x1="-5" y1="30" x2="-40" y2="70" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.9"/>
|
||||||
|
<line x1="5" y1="30" x2="30" y2="75" stroke="white" stroke-width="6" stroke-linecap="round" opacity="0.9"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Flying disc -->
|
||||||
|
<g transform="translate(480, 100) rotate(-20)">
|
||||||
|
<ellipse cx="0" cy="4" rx="32" ry="10" fill="url(#disc-grad)" opacity="0.3"/>
|
||||||
|
<ellipse cx="0" cy="0" rx="28" ry="28" fill="url(#disc-grad)"/>
|
||||||
|
<ellipse cx="0" cy="0" rx="18" ry="18" fill="none" stroke="white" stroke-width="1.5" opacity="0.4"/>
|
||||||
|
<ellipse cx="0" cy="0" rx="8" ry="8" fill="white" opacity="0.2"/>
|
||||||
|
<!-- Motion trails -->
|
||||||
|
<line x1="-40" y1="5" x2="-55" y2="8" stroke="#fb923c" stroke-width="2" opacity="0.5"/>
|
||||||
|
<line x1="-38" y1="12" x2="-52" y2="14" stroke="#fb923c" stroke-width="2" opacity="0.4"/>
|
||||||
|
<line x1="-42" y1="-2" x2="-54" y2="0" stroke="#fb923c" stroke-width="2" opacity="0.3"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Decorative dots -->
|
||||||
|
<circle cx="120" cy="80" r="2" fill="#4ade80" opacity="0.3"/>
|
||||||
|
<circle cx="680" cy="120" r="3" fill="#4ade80" opacity="0.2"/>
|
||||||
|
<circle cx="200" cy="300" r="2" fill="#38bdf8" opacity="0.2"/>
|
||||||
|
<circle cx="600" cy="280" r="2" fill="#38bdf8" opacity="0.15"/>
|
||||||
|
<circle cx="150" cy="200" r="1.5" fill="white" opacity="0.1"/>
|
||||||
|
<circle cx="650" cy="60" r="1.5" fill="white" opacity="0.15"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
31
frontend/src/App.jsx
Normal file
31
frontend/src/App.jsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import Header from './components/Header';
|
||||||
|
import Footer from './components/Footer';
|
||||||
|
import HomePage from './pages/HomePage';
|
||||||
|
import TournamentPage from './pages/TournamentPage';
|
||||||
|
import SchedulePage from './pages/SchedulePage';
|
||||||
|
import GamePage from './pages/GamePage';
|
||||||
|
import QuestionnairePage from './pages/QuestionnairePage';
|
||||||
|
import ResultsPage from './pages/ResultsPage';
|
||||||
|
import PastPage from './pages/PastPage';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<div className="app">
|
||||||
|
<Header />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/tournament/fujarna-14-3-2026" replace />} />
|
||||||
|
<Route path="/tournament/:id" element={<TournamentPage />} />
|
||||||
|
<Route path="/tournament/:id/schedule" element={<SchedulePage />} />
|
||||||
|
<Route path="/tournament/:id/game/:gid" element={<GamePage />} />
|
||||||
|
<Route path="/tournament/:id/questionnaire" element={<QuestionnairePage />} />
|
||||||
|
<Route path="/tournament/:id/results" element={<ResultsPage />} />
|
||||||
|
<Route path="/past" element={<PastPage />} />
|
||||||
|
</Routes>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/api.js
Normal file
38
frontend/src/api.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
async function fetchJSON(path) {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`);
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON(path, body) {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTournaments = () => fetchJSON('/tournaments');
|
||||||
|
export const getTournament = (id) => fetchJSON(`/tournaments/${id}`);
|
||||||
|
export const getSchedule = (id) => fetchJSON(`/tournaments/${id}/schedule`);
|
||||||
|
export const getScore = (tid, gid) => fetchJSON(`/tournaments/${tid}/games/${gid}/score`);
|
||||||
|
export const updateScore = (tid, gid, u) => postJSON(`/tournaments/${tid}/games/${gid}/score`, u);
|
||||||
|
export const getAuditLog = (tid, gid) => fetchJSON(`/tournaments/${tid}/games/${gid}/audit`);
|
||||||
|
export const getQuestionnaire = (id) => fetchJSON(`/tournaments/${id}/questionnaire`);
|
||||||
|
export const submitQuestionnaire = (id, d) => postJSON(`/tournaments/${id}/questionnaire`, d);
|
||||||
|
export const getQuestionnaireResults = (id) => fetchJSON(`/tournaments/${id}/questionnaire/results`);
|
||||||
|
export const getResults = (id) => fetchJSON(`/tournaments/${id}/results`);
|
||||||
|
|
||||||
|
export function createGameWebSocket(tourneyId, gameId, userId = 'anon') {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return new WebSocket(`${proto}//${location.host}/ws/game/${tourneyId}/${gameId}?user_id=${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTournamentWebSocket(tourneyId) {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return new WebSocket(`${proto}//${location.host}/ws/tournament/${tourneyId}`);
|
||||||
|
}
|
||||||
9
frontend/src/components/Footer.jsx
Normal file
9
frontend/src/components/Footer.jsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
return (
|
||||||
|
<footer className="site-footer">
|
||||||
|
<p>Disc Agenda — Turnajová platforma pro ultimate frisbee © {new Date().getFullYear()}</p>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/components/Header.jsx
Normal file
33
frontend/src/components/Header.jsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { FlyingDiscIcon } from './Icons';
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ to: '/tournament/fujarna-14-3-2026', label: 'Domů' },
|
||||||
|
{ to: '/past', label: 'Archiv' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="site-header">
|
||||||
|
<div className="header-inner">
|
||||||
|
<Link to="/tournament/fujarna-14-3-2026" className="header-logo">
|
||||||
|
<FlyingDiscIcon size={36} />
|
||||||
|
</Link>
|
||||||
|
<nav className="header-nav">
|
||||||
|
{navLinks.map(({ to, label }) => (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={pathname === to ? 'active' : ''}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
frontend/src/components/Icons.jsx
Normal file
87
frontend/src/components/Icons.jsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function DiscIcon({ size = 36, className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 40 40" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<ellipse cx="20" cy="22" rx="18" ry="8" fill="currentColor" opacity="0.15"/>
|
||||||
|
<ellipse cx="20" cy="18" rx="16" ry="16" fill="none" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<ellipse cx="20" cy="18" rx="10" ry="10" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.5"/>
|
||||||
|
<ellipse cx="20" cy="18" rx="4" ry="4" fill="currentColor" opacity="0.3"/>
|
||||||
|
<path d="M8 14 C12 8, 28 8, 32 14" stroke="currentColor" strokeWidth="1.5" fill="none" opacity="0.4"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlyingDiscIcon({ size = 48, className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 60 60" className={className} fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g transform="rotate(-25, 30, 30)">
|
||||||
|
<ellipse cx="30" cy="30" rx="22" ry="8" fill="currentColor" opacity="0.2"/>
|
||||||
|
<ellipse cx="30" cy="26" rx="20" ry="20" fill="none" stroke="currentColor" strokeWidth="2.5"/>
|
||||||
|
<ellipse cx="30" cy="26" rx="12" ry="12" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.4"/>
|
||||||
|
<ellipse cx="30" cy="26" rx="5" ry="5" fill="currentColor" opacity="0.25"/>
|
||||||
|
</g>
|
||||||
|
{/* Motion lines */}
|
||||||
|
<line x1="8" y1="20" x2="2" y2="22" stroke="currentColor" strokeWidth="1.5" opacity="0.3"/>
|
||||||
|
<line x1="10" y1="26" x2="3" y2="27" stroke="currentColor" strokeWidth="1.5" opacity="0.4"/>
|
||||||
|
<line x1="9" y1="32" x2="4" y2="32" stroke="currentColor" strokeWidth="1.5" opacity="0.2"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapPinIcon({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/>
|
||||||
|
<circle cx="12" cy="10" r="3"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarIcon({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersIcon({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClockIcon({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrophyIcon({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldIcon({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="1"/><line x1="12" y1="4" x2="12" y2="20"/><circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChevronRightIcon({ size = 18 }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
frontend/src/main.jsx
Normal file
6
frontend/src/main.jsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles/global.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(<App />);
|
||||||
197
frontend/src/pages/GamePage.jsx
Normal file
197
frontend/src/pages/GamePage.jsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { getSchedule, getScore, getAuditLog, createGameWebSocket } from '../api';
|
||||||
|
|
||||||
|
function generateUserId() {
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem('scorer_id');
|
||||||
|
if (stored) return stored;
|
||||||
|
const id = 'user_' + Math.random().toString(36).slice(2, 8);
|
||||||
|
sessionStorage.setItem('scorer_id', id);
|
||||||
|
return id;
|
||||||
|
} catch { return 'user_' + Math.random().toString(36).slice(2, 8); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GamePage() {
|
||||||
|
const { id, gid } = useParams();
|
||||||
|
const [game, setGame] = useState(null);
|
||||||
|
const [score, setScore] = useState({ home_score: 0, away_score: 0 });
|
||||||
|
const [gameStatus, setGameStatus] = useState('scheduled');
|
||||||
|
const [wsStatus, setWsStatus] = useState('connecting');
|
||||||
|
const [auditLog, setAuditLog] = useState([]);
|
||||||
|
const [showAudit, setShowAudit] = useState(false);
|
||||||
|
const [showSetModal, setShowSetModal] = useState(null);
|
||||||
|
const [setVal, setSetVal] = useState('');
|
||||||
|
const wsRef = useRef(null);
|
||||||
|
const userId = useRef(generateUserId());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSchedule(id).then(sched => {
|
||||||
|
const g = sched.games?.find(x => x.id === gid);
|
||||||
|
if (g) {
|
||||||
|
setGame(g);
|
||||||
|
setScore({ home_score: g.home_score, away_score: g.away_score });
|
||||||
|
if (g.status) setGameStatus(g.status);
|
||||||
|
}
|
||||||
|
}).catch(console.error);
|
||||||
|
// Fetch persisted score for latest state (schedule may lag behind)
|
||||||
|
getScore(id, gid).then(state => {
|
||||||
|
if (state && state.status) {
|
||||||
|
setScore({ home_score: state.home_score, away_score: state.away_score });
|
||||||
|
setGameStatus(state.status);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [id, gid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ws;
|
||||||
|
let reconnectTimer;
|
||||||
|
function connect() {
|
||||||
|
setWsStatus('connecting');
|
||||||
|
ws = createGameWebSocket(id, gid, userId.current);
|
||||||
|
wsRef.current = ws;
|
||||||
|
ws.onopen = () => setWsStatus('connected');
|
||||||
|
ws.onclose = () => { setWsStatus('disconnected'); reconnectTimer = setTimeout(connect, 3000); };
|
||||||
|
ws.onerror = () => ws.close();
|
||||||
|
ws.onmessage = (evt) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(evt.data);
|
||||||
|
if (msg.state) {
|
||||||
|
setScore({ home_score: msg.state.home_score, away_score: msg.state.away_score });
|
||||||
|
if (msg.state.status) setGameStatus(msg.state.status);
|
||||||
|
}
|
||||||
|
if (msg.audit) setAuditLog(prev => [msg.audit, ...prev]);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
return () => { clearTimeout(reconnectTimer); if (ws) ws.close(); };
|
||||||
|
}, [id, gid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAudit) {
|
||||||
|
getAuditLog(id, gid).then(entries => { if (entries) setAuditLog(entries.reverse()); }).catch(console.error);
|
||||||
|
}
|
||||||
|
}, [showAudit, id, gid]);
|
||||||
|
|
||||||
|
const sendAction = useCallback((action, team, value) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify({ action, team, value: value || 0 }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSet = (team) => {
|
||||||
|
const v = parseInt(setVal, 10);
|
||||||
|
if (!isNaN(v) && v >= 0) { sendAction('set', team, v); setShowSetModal(null); setSetVal(''); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!game) return <div className="loading"><div className="spinner" /> Načítání zápasu...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-content">
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<Link to={`/tournament/${id}/schedule`} className="btn btn-ghost btn-sm">← Rozpis</Link>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)' }}>
|
||||||
|
{game.round} · {game.field}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scoreboard">
|
||||||
|
<div className="scoreboard-header">
|
||||||
|
{gameStatus === 'final' ? 'Konečné skóre' : 'Živé skóre'}
|
||||||
|
</div>
|
||||||
|
<div className="scoreboard-teams">
|
||||||
|
<div className="scoreboard-team-name">{game.home_team}</div>
|
||||||
|
<div className="scoreboard-score">
|
||||||
|
<span>{score.home_score}</span>
|
||||||
|
<span className="divider">:</span>
|
||||||
|
<span>{score.away_score}</span>
|
||||||
|
</div>
|
||||||
|
<div className="scoreboard-team-name">{game.away_team}</div>
|
||||||
|
</div>
|
||||||
|
{gameStatus !== 'final' && (
|
||||||
|
<div className="score-controls">
|
||||||
|
{['home', 'away'].map(team => (
|
||||||
|
<div key={team} className="score-controls-row">
|
||||||
|
<span className="score-team-label" style={{ textAlign: 'right' }}>{team === 'home' ? game.home_team : game.away_team}</span>
|
||||||
|
<button className="score-btn minus" onClick={() => sendAction('decrement', team)}>−</button>
|
||||||
|
<button className="score-btn plus" onClick={() => sendAction('increment', team)}>+</button>
|
||||||
|
<button className="score-btn" onClick={() => { setShowSetModal(team); setSetVal(String(team === 'home' ? score.home_score : score.away_score)); }} style={{ fontSize: '0.7rem', fontFamily: 'var(--font-heading)' }}>SET</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: '1rem', textAlign: 'center', display: 'flex', gap: '0.5rem', justifyContent: 'center' }}>
|
||||||
|
{gameStatus === 'scheduled' && (
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={() => sendAction('set_status', 'live')}>
|
||||||
|
Zahájit zápas
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{gameStatus === 'live' && (
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={() => sendAction('set_status', 'final')}>
|
||||||
|
Ukončit zápas
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{gameStatus === 'final' && (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-sm btn-outline" onClick={() => sendAction('set_status', 'live')}>
|
||||||
|
Znovu otevřít zápas
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm btn-ghost" onClick={() => sendAction('set_status', 'scheduled')}>
|
||||||
|
Zpět na naplánovaný
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ws-status">
|
||||||
|
<span className={`ws-dot ${wsStatus}`} />
|
||||||
|
{wsStatus === 'connected' ? 'Připojeno' : wsStatus === 'connecting' ? 'Připojování...' : 'Obnovování spojení...'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
|
||||||
|
<QRCodeSVG value={window.location.href} size={160} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSetModal && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={() => setShowSetModal(null)}>
|
||||||
|
<div className="card card-body" style={{ minWidth: 280 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 style={{ fontFamily: 'var(--font-display)', fontSize: '1.3rem', marginBottom: '1rem' }}>
|
||||||
|
Nastavit skóre — {showSetModal === 'home' ? game.home_team : game.away_team}
|
||||||
|
</h3>
|
||||||
|
<input type="number" min="0" className="form-input" value={setVal} onChange={e => setSetVal(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && handleSet(showSetModal)} />
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
||||||
|
<button className="btn btn-primary" onClick={() => handleSet(showSetModal)}>Nastavit</button>
|
||||||
|
<button className="btn btn-ghost" onClick={() => setShowSetModal(null)}>Zrušit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '2rem' }}>
|
||||||
|
<button className="btn btn-outline btn-sm" onClick={() => setShowAudit(!showAudit)}>
|
||||||
|
{showAudit ? 'Skrýt' : 'Zobrazit'} historii změn
|
||||||
|
</button>
|
||||||
|
{showAudit && (
|
||||||
|
<div className="card" style={{ marginTop: '1rem' }}>
|
||||||
|
<div className="card-header">
|
||||||
|
<span style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, fontSize: '0.85rem', textTransform: 'uppercase' }}>Historie skóre</span>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{auditLog.length} záznamů</span>
|
||||||
|
</div>
|
||||||
|
<div className="audit-log">
|
||||||
|
{auditLog.length === 0 ? (
|
||||||
|
<div style={{ padding: '1rem', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '0.85rem' }}>Zatím žádné změny.</div>
|
||||||
|
) : auditLog.map((e, i) => (
|
||||||
|
<div key={i} className="audit-entry">
|
||||||
|
<span><strong>{e.action}</strong> {e.team} {e.action === 'set' ? `→ ${e.value}` : ''} ({e.old_home}:{e.old_away} → {e.new_home}:{e.new_away})</span>
|
||||||
|
<span style={{ color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>{e.user_id} · {new Date(e.timestamp).toLocaleTimeString('cs-CZ')}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
frontend/src/pages/HomePage.jsx
Normal file
123
frontend/src/pages/HomePage.jsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { getTournaments } from '../api';
|
||||||
|
import { MapPinIcon, CalendarIcon, UsersIcon, ChevronRightIcon } from '../components/Icons';
|
||||||
|
|
||||||
|
function statusLabel(s) {
|
||||||
|
if (s === 'in_progress') return { text: 'Právě probíhá', cls: 'live', dot: '' };
|
||||||
|
if (s === 'upcoming') return { text: 'Nadcházející', cls: 'upcoming', dot: 'orange' };
|
||||||
|
return { text: 'Ukončený', cls: 'completed', dot: 'slate' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
return new Date(d + 'T00:00:00').toLocaleDateString('cs-CZ', {
|
||||||
|
day: 'numeric', month: 'short', year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
const [tournaments, setTournaments] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getTournaments()
|
||||||
|
.then(setTournaments)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div className="loading"><div className="spinner" /> Načítání turnajů...</div>;
|
||||||
|
|
||||||
|
// Find active or nearest upcoming
|
||||||
|
const active = tournaments.find(t => t.status === 'in_progress');
|
||||||
|
const upcoming = tournaments.filter(t => t.status === 'upcoming').sort((a, b) => a.start_date.localeCompare(b.start_date));
|
||||||
|
const featured = active || upcoming[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{featured && <FeaturedHero tournament={featured} />}
|
||||||
|
<div className="page-content">
|
||||||
|
{upcoming.length > 0 && !active && upcoming.length > 1 && (
|
||||||
|
<section style={{ marginBottom: '2.5rem' }}>
|
||||||
|
<h2 className="section-title">Další nadcházející</h2>
|
||||||
|
<div className="tournament-grid">
|
||||||
|
{upcoming.slice(1).map(t => <TournamentCard key={t.id} tournament={t} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{active && upcoming.length > 0 && (
|
||||||
|
<section style={{ marginBottom: '2.5rem' }}>
|
||||||
|
<h2 className="section-title">Nadcházející turnaje</h2>
|
||||||
|
<div className="tournament-grid">
|
||||||
|
{upcoming.map(t => <TournamentCard key={t.id} tournament={t} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<section>
|
||||||
|
<Link to="/past" className="btn btn-outline" style={{ marginTop: '1rem' }}>
|
||||||
|
Zobrazit minulé turnaje <ChevronRightIcon size={16} />
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeaturedHero({ tournament: t }) {
|
||||||
|
const st = statusLabel(t.status);
|
||||||
|
return (
|
||||||
|
<div className="hero">
|
||||||
|
<div className="hero-inner">
|
||||||
|
<div className="hero-badge">
|
||||||
|
<span className={`dot ${st.dot}`} />
|
||||||
|
{st.text}
|
||||||
|
</div>
|
||||||
|
<h1>{t.name}</h1>
|
||||||
|
<p>{t.description}</p>
|
||||||
|
<div className="hero-meta">
|
||||||
|
<div className="hero-meta-item">
|
||||||
|
<CalendarIcon /> {formatDate(t.start_date)} — {formatDate(t.end_date)}
|
||||||
|
</div>
|
||||||
|
<div className="hero-meta-item">
|
||||||
|
<MapPinIcon /> {t.venue}, {t.location}
|
||||||
|
</div>
|
||||||
|
<div className="hero-meta-item">
|
||||||
|
<UsersIcon /> {t.teams?.length || 0} týmů
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '2rem', display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
|
<Link to={`/tournament/${t.id}`} className="btn btn-primary btn-lg">
|
||||||
|
Detail turnaje <ChevronRightIcon size={16} />
|
||||||
|
</Link>
|
||||||
|
<Link to={`/tournament/${t.id}/schedule`} className="btn btn-secondary btn-lg">
|
||||||
|
Rozpis
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TournamentCard({ tournament: t }) {
|
||||||
|
const st = statusLabel(t.status);
|
||||||
|
return (
|
||||||
|
<Link to={`/tournament/${t.id}`} className="card game-link" style={{ position: 'relative' }}>
|
||||||
|
<div className="tournament-card-status">
|
||||||
|
<span className={`status-badge ${st.cls}`}>
|
||||||
|
<span className={`dot ${st.dot}`} style={{ width: 6, height: 6, borderRadius: '50%', display: 'inline-block' }} />
|
||||||
|
{st.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="tournament-card-header">
|
||||||
|
<h3>{t.name}</h3>
|
||||||
|
<div className="tournament-card-date">{formatDate(t.start_date)} — {formatDate(t.end_date)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="tournament-card-body">
|
||||||
|
<div className="tournament-card-meta">
|
||||||
|
<span><MapPinIcon size={16} /> {t.location}</span>
|
||||||
|
<span><UsersIcon size={16} /> {t.teams?.length || 0} týmů</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
frontend/src/pages/PastPage.jsx
Normal file
61
frontend/src/pages/PastPage.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { getTournaments } from '../api';
|
||||||
|
import { MapPinIcon, CalendarIcon, UsersIcon, TrophyIcon, ChevronRightIcon } from '../components/Icons';
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
return new Date(d + 'T00:00:00').toLocaleDateString('cs-CZ', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PastPage() {
|
||||||
|
const [tournaments, setTournaments] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getTournaments()
|
||||||
|
.then(ts => setTournaments(ts.filter(t => t.status === 'completed')))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div className="loading"><div className="spinner" /> Načítání...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-content">
|
||||||
|
<h1 className="section-title">Minulé turnaje</h1>
|
||||||
|
{tournaments.length === 0 ? (
|
||||||
|
<div className="card card-body" style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-secondary)' }}>
|
||||||
|
Zatím žádné minulé turnaje.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="tournament-grid">
|
||||||
|
{tournaments.map(t => (
|
||||||
|
<div key={t.id} className="card" style={{ position: 'relative' }}>
|
||||||
|
<div className="tournament-card-header" style={{ background: 'linear-gradient(135deg, var(--slate-700), var(--slate-600))' }}>
|
||||||
|
<h3>{t.name}</h3>
|
||||||
|
<div className="tournament-card-date">{formatDate(t.start_date)} — {formatDate(t.end_date)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="tournament-card-body">
|
||||||
|
<div className="tournament-card-meta">
|
||||||
|
<span><MapPinIcon size={16} /> {t.location}</span>
|
||||||
|
<span><UsersIcon size={16} /> {t.teams?.length || 0} týmů</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
||||||
|
<Link to={"/tournament/" + t.id} className="btn btn-outline btn-sm">
|
||||||
|
Detail <ChevronRightIcon size={14} />
|
||||||
|
</Link>
|
||||||
|
<Link to={"/tournament/" + t.id + "/results"} className="btn btn-secondary btn-sm">
|
||||||
|
<TrophyIcon size={14} /> Výsledky
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: '2rem' }}>
|
||||||
|
<Link to="/" className="btn btn-ghost">← Zpět na hlavní stránku</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
frontend/src/pages/QuestionnairePage.jsx
Normal file
95
frontend/src/pages/QuestionnairePage.jsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { getQuestionnaire, submitQuestionnaire } from '../api';
|
||||||
|
|
||||||
|
export default function QuestionnairePage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [config, setConfig] = useState(null);
|
||||||
|
const [teams, setTeams] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [myTeam, setMyTeam] = useState('');
|
||||||
|
const [spiritWinner, setSpiritWinner] = useState('');
|
||||||
|
const [attendNext, setAttendNext] = useState(false);
|
||||||
|
const [customAnswers, setCustomAnswers] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getQuestionnaire(id).then(data => { setConfig(data.config); setTeams(data.teams || []); }).catch(console.error).finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await submitQuestionnaire(id, { my_team: myTeam, spirit_winner: spiritWinner, attend_next: attendNext, custom_answers: customAnswers });
|
||||||
|
setSubmitted(true);
|
||||||
|
} catch (err) { alert('Chyba: ' + err.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="loading"><div className="spinner" /> Načítání...</div>;
|
||||||
|
|
||||||
|
if (submitted) return (
|
||||||
|
<div className="page-content" style={{ textAlign: 'center', paddingTop: '4rem' }}>
|
||||||
|
<div className="success-msg" style={{ maxWidth: 500, margin: '0 auto' }}>
|
||||||
|
<div style={{ fontSize: '3rem', marginBottom: '0.5rem' }}>🥏</div>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: '1.8rem', marginBottom: '0.5rem' }}>Díky!</h2>
|
||||||
|
<p style={{ color: 'var(--text-secondary)' }}>Tvoje odpověď byla zaznamenána.</p>
|
||||||
|
</div>
|
||||||
|
<Link to={`/tournament/${id}`} className="btn btn-primary" style={{ marginTop: '2rem' }}>Zpět na turnaj</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const customQs = config?.custom_questions || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-content" style={{ maxWidth: 640, margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<Link to={`/tournament/${id}`} className="btn btn-ghost btn-sm">← Zpět</Link>
|
||||||
|
</div>
|
||||||
|
<h1 className="section-title">Dotazník po turnaji</h1>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>Pomoz nám se zlepšit! Odpovědi jsou anonymní.</p>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="card card-body" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Můj tým *</label>
|
||||||
|
<select className="form-select" value={myTeam} onChange={e => setMyTeam(e.target.value)} required>
|
||||||
|
<option value="">Vyber svůj tým...</option>
|
||||||
|
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Spirit winner *</label>
|
||||||
|
<select className="form-select" value={spiritWinner} onChange={e => setSpiritWinner(e.target.value)} required>
|
||||||
|
<option value="">Vyber tým se spirit...</option>
|
||||||
|
{teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>Který tým měl nejlepší spirit?</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-check">
|
||||||
|
<input type="checkbox" checked={attendNext} onChange={e => setAttendNext(e.target.checked)} />
|
||||||
|
<span>Chci se zúčastnit příštího turnaje</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{customQs.length > 0 && (
|
||||||
|
<div className="card card-body" style={{ marginBottom: '1.5rem' }}>
|
||||||
|
{customQs.map(q => (
|
||||||
|
<div key={q.id} className="form-group">
|
||||||
|
<label className="form-label">{q.text} {q.required && '*'}</label>
|
||||||
|
{q.type === 'select' || q.type === 'radio' ? (
|
||||||
|
<select className="form-select" value={customAnswers[q.id] || ''} onChange={e => setCustomAnswers(p => ({ ...p, [q.id]: e.target.value }))} required={q.required}>
|
||||||
|
<option value="">Vyber...</option>
|
||||||
|
{q.options?.map(o => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<textarea className="form-textarea" value={customAnswers[q.id] || ''} onChange={e => setCustomAnswers(p => ({ ...p, [q.id]: e.target.value }))} required={q.required} placeholder="Tvoje odpověď..." />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="submit" className="btn btn-primary btn-lg" style={{ width: '100%' }}>Odeslat odpověď</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/src/pages/ResultsPage.jsx
Normal file
68
frontend/src/pages/ResultsPage.jsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { getResults } from '../api';
|
||||||
|
import { TrophyIcon } from '../components/Icons';
|
||||||
|
|
||||||
|
function medal(pos) {
|
||||||
|
if (pos === 1) return <span className="medal-1">🥇</span>;
|
||||||
|
if (pos === 2) return <span className="medal-2">🥈</span>;
|
||||||
|
if (pos === 3) return <span className="medal-3">🥉</span>;
|
||||||
|
return <span>{pos}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResultsPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [results, setResults] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getResults(id).then(setResults).catch(console.error).finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) return <div className="loading"><div className="spinner" /> Načítání výsledků...</div>;
|
||||||
|
|
||||||
|
const standings = results?.standings || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-content">
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<Link to={`/tournament/${id}`} className="btn btn-ghost btn-sm">← Zpět na turnaj</Link>
|
||||||
|
</div>
|
||||||
|
<h1 className="section-title"><TrophyIcon size={28} /> Konečné výsledky</h1>
|
||||||
|
{standings.length === 0 ? (
|
||||||
|
<div className="card card-body" style={{ textAlign: 'center', padding: '3rem', color: 'var(--text-secondary)' }}>Výsledky zatím nejsou k dispozici.</div>
|
||||||
|
) : (
|
||||||
|
<div className="card" style={{ overflow: 'auto' }}>
|
||||||
|
<table className="results-table">
|
||||||
|
<thead><tr><th>#</th><th>Tým</th><th>V</th><th>P</th><th>R</th><th>Body+</th><th>Body-</th><th>+/-</th><th>Spirit</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{standings.map(s => (
|
||||||
|
<tr key={s.team_id}>
|
||||||
|
<td className="position">{medal(s.position)}</td>
|
||||||
|
<td className="team-name">{s.team_name}</td>
|
||||||
|
<td>{s.wins}</td><td>{s.losses}</td><td>{s.draws}</td>
|
||||||
|
<td>{s.points_for}</td><td>{s.points_against}</td>
|
||||||
|
<td style={{ color: s.points_for - s.points_against > 0 ? 'var(--green-600)' : 'var(--red-500)', fontWeight: 600 }}>
|
||||||
|
{s.points_for - s.points_against > 0 ? '+' : ''}{s.points_for - s.points_against}
|
||||||
|
</td>
|
||||||
|
<td>{s.spirit_score?.toFixed(1) || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{standings.length > 0 && (() => {
|
||||||
|
const sw = [...standings].sort((a, b) => (b.spirit_score || 0) - (a.spirit_score || 0))[0];
|
||||||
|
return sw?.spirit_score ? (
|
||||||
|
<div className="card card-body" style={{ marginTop: '1.5rem', textAlign: 'center', background: 'var(--green-50)', borderColor: 'var(--green-400)' }}>
|
||||||
|
<div style={{ fontSize: '2rem', marginBottom: '0.25rem' }}>🕊️</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 700, textTransform: 'uppercase', fontSize: '0.8rem', letterSpacing: '0.06em', color: 'var(--green-700)' }}>Spirit Award</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-display)', fontSize: '1.8rem' }}>{sw.team_name}</div>
|
||||||
|
<div style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>Spirit skóre: {sw.spirit_score.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
frontend/src/pages/SchedulePage.jsx
Normal file
119
frontend/src/pages/SchedulePage.jsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { getSchedule, createTournamentWebSocket } from '../api';
|
||||||
|
import { ClockIcon } from '../components/Icons';
|
||||||
|
|
||||||
|
function formatTime(dt) {
|
||||||
|
return new Date(dt).toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupBySection(games) {
|
||||||
|
const poolA = [];
|
||||||
|
const poolB = [];
|
||||||
|
const placement = [];
|
||||||
|
for (const g of games) {
|
||||||
|
if (g.round.startsWith('Pool A')) poolA.push(g);
|
||||||
|
else if (g.round.startsWith('Pool B')) poolB.push(g);
|
||||||
|
else placement.push(g);
|
||||||
|
}
|
||||||
|
const sections = [];
|
||||||
|
if (poolA.length) sections.push(['Skupina A', poolA]);
|
||||||
|
if (poolB.length) sections.push(['Skupina B', poolB]);
|
||||||
|
if (placement.length) sections.push(['Pavouk', placement]);
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasScore(g) {
|
||||||
|
return g.home_score > 0 || g.away_score > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SchedulePage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [schedule, setSchedule] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchSchedule = useCallback(() => {
|
||||||
|
getSchedule(id).then(setSchedule).catch(console.error).finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSchedule();
|
||||||
|
const onVisibility = () => { if (document.visibilityState === 'visible') fetchSchedule(); };
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
|
||||||
|
// Live updates via tournament WebSocket
|
||||||
|
let ws = null;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
ws = createTournamentWebSocket(id);
|
||||||
|
ws.onmessage = (evt) => {
|
||||||
|
const msg = JSON.parse(evt.data);
|
||||||
|
if (msg.type === 'score_update' && msg.state) {
|
||||||
|
setSchedule(prev => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
games: prev.games.map(g =>
|
||||||
|
g.id === msg.state.game_id
|
||||||
|
? { ...g, home_score: msg.state.home_score, away_score: msg.state.away_score, status: msg.state.status }
|
||||||
|
: g
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
reconnectTimer = setTimeout(connect, 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
if (ws) { ws.onclose = null; ws.close(); }
|
||||||
|
};
|
||||||
|
}, [fetchSchedule, id]);
|
||||||
|
|
||||||
|
if (loading) return <div className="loading"><div className="spinner" /> Načítání rozpisu...</div>;
|
||||||
|
|
||||||
|
const games = schedule?.games || [];
|
||||||
|
const sections = groupBySection(games);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-content">
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<Link to={`/tournament/${id}`} className="btn btn-ghost btn-sm">← Zpět na turnaj</Link>
|
||||||
|
</div>
|
||||||
|
<h1 className="section-title">Rozpis</h1>
|
||||||
|
|
||||||
|
{sections.length === 0 && <p style={{ color: 'var(--text-secondary)' }}>Zatím žádné naplánované zápasy.</p>}
|
||||||
|
|
||||||
|
<div className="schedule-grid">
|
||||||
|
{sections.map(([section, sectionGames]) => (
|
||||||
|
<div key={section} className="schedule-round">
|
||||||
|
<div className="schedule-round-title">{section}</div>
|
||||||
|
{sectionGames.map(g => (
|
||||||
|
<Link to={`/tournament/${id}/game/${g.id}`} key={g.id} className="game-row game-link" style={g.status === 'live' ? { borderColor: 'orange', borderWidth: '2px' } : undefined}>
|
||||||
|
<div className="game-time" style={{ fontWeight: 700, fontSize: '1.05rem', minWidth: '3.2rem' }}>
|
||||||
|
{formatTime(g.start_time)}
|
||||||
|
</div>
|
||||||
|
<div className="game-team home">{g.home_team}</div>
|
||||||
|
<div className="game-score" style={{
|
||||||
|
color: g.status === 'final' ? 'var(--text-primary)' : g.status === 'live' ? 'var(--green-700)' : undefined
|
||||||
|
}}>
|
||||||
|
{g.status === 'scheduled' && !hasScore(g) ? '– : –' : `${g.home_score} : ${g.away_score}`}
|
||||||
|
</div>
|
||||||
|
<div className="game-team away">{g.away_team}</div>
|
||||||
|
<span className={`status-badge ${g.status === 'live' ? 'live' : g.status === 'final' ? 'completed' : 'scheduled'}`}>
|
||||||
|
{g.status}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
frontend/src/pages/TournamentPage.jsx
Normal file
138
frontend/src/pages/TournamentPage.jsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { getTournament } from '../api';
|
||||||
|
import { MapPinIcon, CalendarIcon, UsersIcon, DiscIcon, ChevronRightIcon } from '../components/Icons';
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
return new Date(d + 'T00:00:00').toLocaleDateString('cs-CZ', {
|
||||||
|
weekday: 'short', day: 'numeric', month: 'short', year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TournamentPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [t, setT] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getTournament(id).then(setT).catch(console.error).finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) return <div className="loading"><div className="spinner" /> Načítání...</div>;
|
||||||
|
if (!t) return <div className="page-content"><p>Turnaj nenalezen.</p></div>;
|
||||||
|
|
||||||
|
const questionnaireUrl = `${window.location.origin}/tournament/${id}/questionnaire`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-content">
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<Link to="/" className="btn btn-ghost btn-sm">← Zpět</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="section-title" style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>{t.name}</h1>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem', maxWidth: 700 }}>{t.description}</p>
|
||||||
|
|
||||||
|
{/* Info Cards */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem', marginBottom: '2.5rem' }}>
|
||||||
|
<div className="card card-body" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<CalendarIcon size={24} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>Datum</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{formatDate(t.start_date)} — {formatDate(t.end_date)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card card-body" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<MapPinIcon size={24} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>Místo</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{t.venue}</div>
|
||||||
|
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{t.location}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card card-body" style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<UsersIcon size={24} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-heading)', fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', color: 'var(--text-secondary)' }}>Týmy</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{t.teams?.length || 0} registrovaných</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', marginBottom: '2.5rem' }}>
|
||||||
|
<Link to={`/tournament/${id}/schedule`} className="btn btn-primary btn-lg">
|
||||||
|
Rozpis <ChevronRightIcon size={16} />
|
||||||
|
</Link>
|
||||||
|
<Link to={`/tournament/${id}/results`} className="btn btn-secondary btn-lg">
|
||||||
|
Výsledky
|
||||||
|
</Link>
|
||||||
|
<Link to={`/tournament/${id}/questionnaire`} className="btn btn-outline btn-lg">
|
||||||
|
Dotazník
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Teams */}
|
||||||
|
{t.teams && t.teams.length > 0 && (() => {
|
||||||
|
const poolAIds = ['fuj-1', 'kocicaci', 'spitalska', 'sunset', 'hoko-coko-diskyto'];
|
||||||
|
const poolA = t.teams.filter(team => poolAIds.includes(team.id));
|
||||||
|
const poolB = t.teams.filter(team => !poolAIds.includes(team.id));
|
||||||
|
return (
|
||||||
|
<section style={{ marginBottom: '2.5rem' }}>
|
||||||
|
<h2 className="section-title">Zúčastněné týmy</h2>
|
||||||
|
{poolA.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', marginBottom: '0.5rem' }}>Skupina A</h3>
|
||||||
|
<div className="teams-grid" style={{ marginBottom: '1.25rem' }}>
|
||||||
|
{poolA.map(team => (
|
||||||
|
<div key={team.id} className="team-chip">
|
||||||
|
<DiscIcon size={20} />
|
||||||
|
{team.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{poolB.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', marginBottom: '0.5rem' }}>Skupina B</h3>
|
||||||
|
<div className="teams-grid">
|
||||||
|
{poolB.map(team => (
|
||||||
|
<div key={team.id} className="team-chip">
|
||||||
|
<DiscIcon size={20} />
|
||||||
|
{team.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Rules */}
|
||||||
|
{t.rules && (
|
||||||
|
<section style={{ marginBottom: '2.5rem' }}>
|
||||||
|
<h2 className="section-title">Pravidla</h2>
|
||||||
|
<div className="card card-body">
|
||||||
|
<p>{t.rules}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code for questionnaire */}
|
||||||
|
<section>
|
||||||
|
<h2 className="section-title">Dotazník</h2>
|
||||||
|
<div className="qr-section">
|
||||||
|
<p style={{ marginBottom: '1rem', color: 'var(--text-secondary)' }}>
|
||||||
|
Naskenuj QR kód pro otevření dotazníku na telefonu:
|
||||||
|
</p>
|
||||||
|
<QRCodeSVG value={questionnaireUrl} size={180} level="M" bgColor="transparent" fgColor="#14532d" />
|
||||||
|
<p style={{ marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-secondary)', wordBreak: 'break-all' }}>
|
||||||
|
{questionnaireUrl}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
944
frontend/src/styles/global.css
Normal file
944
frontend/src/styles/global.css
Normal file
@@ -0,0 +1,944 @@
|
|||||||
|
:root {
|
||||||
|
/* Core palette — grass field greens + energetic accents */
|
||||||
|
--green-900: #0d2818;
|
||||||
|
--green-800: #14532d;
|
||||||
|
--green-700: #166534;
|
||||||
|
--green-600: #16a34a;
|
||||||
|
--green-500: #22c55e;
|
||||||
|
--green-400: #4ade80;
|
||||||
|
--green-100: #dcfce7;
|
||||||
|
--green-50: #f0fdf4;
|
||||||
|
|
||||||
|
--sky-500: #0ea5e9;
|
||||||
|
--sky-400: #38bdf8;
|
||||||
|
--sky-300: #7dd3fc;
|
||||||
|
--sky-100: #e0f2fe;
|
||||||
|
|
||||||
|
--orange-500: #f97316;
|
||||||
|
--orange-400: #fb923c;
|
||||||
|
--orange-300: #fdba74;
|
||||||
|
|
||||||
|
--slate-900: #0f172a;
|
||||||
|
--slate-800: #1e293b;
|
||||||
|
--slate-700: #334155;
|
||||||
|
--slate-600: #475569;
|
||||||
|
--slate-400: #94a3b8;
|
||||||
|
--slate-300: #cbd5e1;
|
||||||
|
--slate-200: #e2e8f0;
|
||||||
|
--slate-100: #f1f5f9;
|
||||||
|
--slate-50: #f8fafc;
|
||||||
|
|
||||||
|
--white: #ffffff;
|
||||||
|
--red-500: #ef4444;
|
||||||
|
--red-600: #dc2626;
|
||||||
|
|
||||||
|
/* Semantic */
|
||||||
|
--bg-primary: var(--green-900);
|
||||||
|
--bg-card: var(--white);
|
||||||
|
--bg-surface: var(--slate-50);
|
||||||
|
--text-primary: var(--slate-900);
|
||||||
|
--text-secondary: var(--slate-600);
|
||||||
|
--text-on-dark: var(--green-50);
|
||||||
|
--accent: var(--orange-500);
|
||||||
|
--accent-hover: var(--orange-400);
|
||||||
|
--border: var(--slate-200);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-display: 'Bebas Neue', 'Impact', sans-serif;
|
||||||
|
--font-heading: 'Barlow Condensed', 'Arial Narrow', sans-serif;
|
||||||
|
--font-body: 'Barlow', 'Helvetica Neue', sans-serif;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--radius-xl: 24px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
--shadow-md: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
--shadow-lg: 0 8px 30px rgba(0,0,0,0.12);
|
||||||
|
--shadow-xl: 0 20px 60px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- LAYOUT ---- */
|
||||||
|
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- HEADER / NAV ---- */
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
background: var(--green-900);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 2px 20px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo svg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo-text {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav a {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--green-400);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav a:hover,
|
||||||
|
.header-nav a.active {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- HERO ---- */
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(135deg, var(--green-900) 0%, var(--green-800) 50%, var(--green-700) 100%);
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
padding: 4rem 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 50% at 80% 20%, rgba(34, 197, 94, 0.15), transparent),
|
||||||
|
radial-gradient(ellipse 60% 40% at 20% 80%, rgba(14, 165, 233, 0.1), transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
padding: 0.35rem 1rem;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green-400);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge .dot.orange { background: var(--orange-400); animation: none; }
|
||||||
|
.hero-badge .dot.slate { background: var(--slate-400); animation: none; }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.5; transform: scale(1.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(2.5rem, 6vw, 4.5rem);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: rgba(255,255,255,0.75);
|
||||||
|
max-width: 600px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta-item svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- CARDS ---- */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 0.25s, transform 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- SECTION ---- */
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 2rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--green-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- BUTTONS ---- */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.65rem 1.5rem;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-decoration: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--green-700);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--green-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 2px solid var(--green-600);
|
||||||
|
color: var(--green-700);
|
||||||
|
}
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: var(--green-600);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--slate-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: 0.85rem 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- STATUS BADGES ---- */
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.2rem 0.75rem;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.live {
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
color: var(--green-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.upcoming {
|
||||||
|
background: rgba(14, 165, 233, 0.12);
|
||||||
|
color: var(--sky-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.completed {
|
||||||
|
background: var(--slate-100);
|
||||||
|
color: var(--slate-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.scheduled {
|
||||||
|
background: var(--slate-100);
|
||||||
|
color: var(--slate-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- SCHEDULE TABLE ---- */
|
||||||
|
|
||||||
|
.schedule-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-round {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-round-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--green-700);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid var(--green-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-row:hover {
|
||||||
|
border-color: var(--green-400);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-team {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-team.home { text-align: right; }
|
||||||
|
.game-team.away { text-align: left; }
|
||||||
|
|
||||||
|
.game-score {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- SCOREBOARD ---- */
|
||||||
|
|
||||||
|
.scoreboard {
|
||||||
|
background: var(--green-900);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 2.5rem;
|
||||||
|
color: var(--text-on-dark);
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-xl);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: radial-gradient(ellipse at 50% 0%, rgba(34, 197, 94, 0.15), transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-header {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-teams {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-team-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.2rem, 3vw, 2rem);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-score {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(3rem, 8vw, 6rem);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-score .divider {
|
||||||
|
opacity: 0.3;
|
||||||
|
font-size: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-controls-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-btn {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255,255,255,0.2);
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: var(--white);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-color: rgba(255,255,255,0.4);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-btn.plus { border-color: var(--green-500); color: var(--green-400); }
|
||||||
|
.score-btn.plus:hover { background: rgba(34, 197, 94, 0.25); }
|
||||||
|
.score-btn.minus { border-color: var(--red-500); color: var(--red-500); }
|
||||||
|
.score-btn.minus:hover { background: rgba(239, 68, 68, 0.2); }
|
||||||
|
|
||||||
|
.score-team-label {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
opacity: 0.5;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ws-dot.connected { background: var(--green-400); }
|
||||||
|
.ws-dot.disconnected { background: var(--red-500); }
|
||||||
|
|
||||||
|
/* ---- QUESTIONNAIRE ---- */
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select,
|
||||||
|
.form-input,
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select:focus,
|
||||||
|
.form-input:focus,
|
||||||
|
.form-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--green-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
accent-color: var(--green-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- RESULTS TABLE ---- */
|
||||||
|
|
||||||
|
.results-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table th {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 2px solid var(--green-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table td {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table tr:hover td {
|
||||||
|
background: var(--green-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table .position {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--green-700);
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-table .team-name {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medal-1 { color: #d4a017; }
|
||||||
|
.medal-2 { color: #9ca3af; }
|
||||||
|
.medal-3 { color: #b87333; }
|
||||||
|
|
||||||
|
/* ---- TOURNAMENT CARDS ---- */
|
||||||
|
|
||||||
|
.tournament-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tournament-card-status {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tournament-card-header {
|
||||||
|
padding: 2rem 1.5rem 1.5rem;
|
||||||
|
background: linear-gradient(135deg, var(--green-800), var(--green-700));
|
||||||
|
color: var(--white);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tournament-card-header h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tournament-card-date {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tournament-card-body {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tournament-card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tournament-card-meta span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- TEAMS GRID ---- */
|
||||||
|
|
||||||
|
.teams-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: var(--green-50);
|
||||||
|
border: 1px solid var(--green-100);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--green-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-chip .disc-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--green-600);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- QR CODE ---- */
|
||||||
|
|
||||||
|
.qr-section {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- FOOTER ---- */
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
background: var(--green-900);
|
||||||
|
color: var(--green-400);
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- AUDIT LOG ---- */
|
||||||
|
|
||||||
|
.audit-log {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-entry {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-entry:nth-child(even) {
|
||||||
|
background: var(--slate-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- TABS ---- */
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: none;
|
||||||
|
border-top: none;
|
||||||
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--green-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--green-700);
|
||||||
|
border-bottom-color: var(--green-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- LOADING ---- */
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--green-600);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- SUCCESS MESSAGE ---- */
|
||||||
|
|
||||||
|
.success-msg {
|
||||||
|
background: var(--green-50);
|
||||||
|
border: 1px solid var(--green-400);
|
||||||
|
color: var(--green-800);
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
text-align: center;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- RESPONSIVE ---- */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-inner { height: 56px; }
|
||||||
|
.header-nav a { padding: 0.4rem 0.6rem; font-size: 0.8rem; }
|
||||||
|
.hero { padding: 2.5rem 1rem; }
|
||||||
|
.page-content { padding: 1.25rem 1rem; }
|
||||||
|
|
||||||
|
.game-row {
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.game-meta { display: none; }
|
||||||
|
.game-row .btn { display: none; }
|
||||||
|
|
||||||
|
.scoreboard { padding: 1.5rem 1rem; }
|
||||||
|
.scoreboard-teams { gap: 1rem; }
|
||||||
|
.score-btn { width: 48px; height: 48px; font-size: 1.25rem; }
|
||||||
|
.tournament-grid { grid-template-columns: 1fr; }
|
||||||
|
.teams-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.header-logo-text { font-size: 1.3rem; }
|
||||||
|
.header-nav a { padding: 0.35rem 0.45rem; font-size: 0.7rem; }
|
||||||
|
.score-btn { width: 44px; height: 44px; }
|
||||||
|
}
|
||||||
19
frontend/vite.config.js
Normal file
19
frontend/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8080',
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:8080',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user