Initial release: Disc Agenda frisbee tournament platform
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