Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd19e0682b | |||
| 27143319e3 | |||
| 2465bc2ec3 | |||
| cb68451c1c |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
frontend/dist
|
||||||
|
bin
|
||||||
|
backend/data
|
||||||
|
data
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
.DS_Store
|
||||||
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
APP_PORT=8080
|
||||||
|
IMAGE=repo.ssp-itinfra.com/admin/shooting-event:amd64-latest
|
||||||
|
ADMIN_USER=datwyler
|
||||||
|
ADMIN_PASS=datwyler
|
||||||
|
GEMINI_API_KEY=replace-with-your-key
|
||||||
|
GEMINI_MODEL=gemini-2.0-flash
|
||||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Node
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
# Go binaries
|
||||||
|
bin/
|
||||||
|
backend/backend
|
||||||
|
|
||||||
|
# Runtime/generated web assets
|
||||||
|
backend/web/
|
||||||
|
|
||||||
|
# SQLite runtime data
|
||||||
|
backend/data/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||||
|
RUN corepack enable && pnpm install --frozen-lockfile
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:1.24 AS backend-builder
|
||||||
|
WORKDIR /app/backend
|
||||||
|
COPY backend/go.mod backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY backend/ ./
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist ./web
|
||||||
|
ARG TARGETARCH
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} go build -trimpath -ldflags="-s -w" -o /out/shooting-event .
|
||||||
|
|
||||||
|
FROM --platform=$TARGETPLATFORM alpine:3.21
|
||||||
|
RUN addgroup -S app && adduser -S app -G app && apk add --no-cache ca-certificates
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=backend-builder /out/shooting-event /app/shooting-event
|
||||||
|
COPY --from=backend-builder /app/backend/web /app/web
|
||||||
|
RUN mkdir -p /app/data && chown -R app:app /app
|
||||||
|
USER app
|
||||||
|
ENV PORT=8080
|
||||||
|
ENV DB_PATH=/app/data/shooting.db
|
||||||
|
ENV WEB_DIR=/app/web
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/app/shooting-event"]
|
||||||
59
Makefile
Normal file
59
Makefile
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
SHELL := /bin/bash
|
||||||
|
|
||||||
|
PNPM ?= pnpm
|
||||||
|
GO ?= go
|
||||||
|
CONTAINER_REG ?= repo.ssp-itinfra.com/admin
|
||||||
|
IMAGE_NAME ?= shooting-event
|
||||||
|
ARCH ?= amd64
|
||||||
|
BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
BUILD_VERSION ?= $(shell git describe --tags --always --dirty)
|
||||||
|
|
||||||
|
.PHONY: install dev dev-backend dev-frontend build build-frontend build-backend docker-build docker-run clean
|
||||||
|
|
||||||
|
install:
|
||||||
|
cd frontend && $(PNPM) install
|
||||||
|
cd backend && $(GO) mod tidy
|
||||||
|
|
||||||
|
dev-backend:
|
||||||
|
cd backend && $(GO) run .
|
||||||
|
|
||||||
|
dev-frontend:
|
||||||
|
cd frontend && $(PNPM) dev
|
||||||
|
|
||||||
|
dev:
|
||||||
|
@set -euo pipefail; \
|
||||||
|
trap 'kill 0' INT TERM EXIT; \
|
||||||
|
$(MAKE) dev-backend & \
|
||||||
|
$(MAKE) dev-frontend & \
|
||||||
|
wait
|
||||||
|
|
||||||
|
build-frontend:
|
||||||
|
cd frontend && $(PNPM) build
|
||||||
|
|
||||||
|
build-backend:
|
||||||
|
mkdir -p bin
|
||||||
|
cd backend && $(GO) build -o ../bin/shooting-event .
|
||||||
|
|
||||||
|
build: build-frontend
|
||||||
|
rm -rf backend/web
|
||||||
|
mkdir -p backend/web
|
||||||
|
cp -R frontend/dist/. backend/web/
|
||||||
|
$(MAKE) build-backend
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker buildx build --load --platform=linux/$(ARCH) -t $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) .
|
||||||
|
docker tag $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-latest
|
||||||
|
docker tag $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
|
||||||
|
docker push $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)
|
||||||
|
docker push $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-latest
|
||||||
|
docker push $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
mkdir -p data
|
||||||
|
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)
|
||||||
|
|
||||||
|
release:
|
||||||
|
$(MAKE) docker-build
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin frontend/dist backend/web
|
||||||
151
README.md
Normal file
151
README.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Datwyler Shooting Event System
|
||||||
|
|
||||||
|
Production-ready full-stack web app based on your original live score concept.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Frontend: Vue 3 + Vite (pnpm)
|
||||||
|
- Backend: Go + Echo
|
||||||
|
- Database: SQLite
|
||||||
|
- Packaging: Single Docker image (frontend + backend)
|
||||||
|
|
||||||
|
## Main Features
|
||||||
|
|
||||||
|
- Bilingual UI: Arabic and English
|
||||||
|
- Runtime RTL/LTR switching
|
||||||
|
- Admin avatar crop/fit tool (drag + zoom before saving)
|
||||||
|
- AI score advisor for proof images (Gemini-powered suggestion + optional apply)
|
||||||
|
- Two clean modes:
|
||||||
|
- View Only screen for players/coaches/audience
|
||||||
|
- Admin Control Panel (login required)
|
||||||
|
- Admin credentials (default):
|
||||||
|
- Username: `datwyler`
|
||||||
|
- Password: `datwyler`
|
||||||
|
|
||||||
|
## Tournament Flow Implemented
|
||||||
|
|
||||||
|
1. Admin registers players and assigns groups (no hard 6-player limit).
|
||||||
|
2. View screen shows group assignment clearly.
|
||||||
|
3. Admin enters preliminary scores.
|
||||||
|
4. Overall ranking auto-calculates and highlights top 12 finalists.
|
||||||
|
5. If rank #12 cutoff is tied, qualification tie-break stage appears.
|
||||||
|
6. Top 12 split into final groups (1-6 and 7-12 seeds).
|
||||||
|
7. Admin enters final scores.
|
||||||
|
8. Podium is determined automatically.
|
||||||
|
9. If top-3 tie exists, podium tie-break stage appears.
|
||||||
|
|
||||||
|
## API Highlights
|
||||||
|
|
||||||
|
Public:
|
||||||
|
|
||||||
|
- `GET /api/health`
|
||||||
|
- `GET /api/state`
|
||||||
|
|
||||||
|
Admin:
|
||||||
|
|
||||||
|
- `POST /api/admin/login`
|
||||||
|
- `POST /api/admin/logout`
|
||||||
|
- `POST /api/admin/players`
|
||||||
|
- `PUT /api/admin/players/:id`
|
||||||
|
- `DELETE /api/admin/players/:id`
|
||||||
|
- `PUT /api/admin/scores/:stage/:id`
|
||||||
|
- `POST /api/admin/scores/:stage/:id/advice`
|
||||||
|
- `POST /api/admin/scores/:stage/reset`
|
||||||
|
|
||||||
|
Stages:
|
||||||
|
|
||||||
|
- `preliminary`
|
||||||
|
- `prelim_tiebreak`
|
||||||
|
- `final`
|
||||||
|
- `final_tiebreak`
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
Run backend + frontend together:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Backend dev port is `18081`.
|
||||||
|
- Frontend runs on `5173` (or next free port, e.g. `5174` if busy).
|
||||||
|
- Frontend proxy is configured so `/api/*` works from Vite dev server.
|
||||||
|
|
||||||
|
Run individually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev-backend
|
||||||
|
make dev-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds frontend assets, copies them into backend `web/`, and compiles backend binary.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Build image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker-build ARCH=amd64
|
||||||
|
# or
|
||||||
|
make docker-build ARCH=arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
Run image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker-run ARCH=amd64
|
||||||
|
# or
|
||||||
|
make docker-run ARCH=arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose (Production)
|
||||||
|
|
||||||
|
1. Copy environment template:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `.env` for production credentials/tag.
|
||||||
|
|
||||||
|
3. Start service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Check health:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
5. View logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime Environment Variables
|
||||||
|
|
||||||
|
- `PORT` (default `8080`)
|
||||||
|
- `DB_PATH` (default `./data/shooting.db`)
|
||||||
|
- `WEB_DIR` (default `./web`)
|
||||||
|
- `ADMIN_USER` (default `datwyler`)
|
||||||
|
- `ADMIN_PASS` (default `datwyler`)
|
||||||
|
- `GEMINI_API_KEY` (required for AI score advisor)
|
||||||
|
- `GEMINI_MODEL` (default `gemini-2.0-flash`)
|
||||||
1
backend/.env
Normal file
1
backend/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
GEMINI_API_KEY=AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc
|
||||||
95
backend/ai_handlers.go
Normal file
95
backend/ai_handlers.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) handleScoreAdvice(c echo.Context) error {
|
||||||
|
stage, err := validateStage(c.Param("stage"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(a.cfg.GeminiAPIKey) == "" {
|
||||||
|
return writeError(c, http.StatusServiceUnavailable, "gemini api key is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
proofImageData, err := a.loadScoreProofImage(stage, playerID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return writeError(c, http.StatusBadRequest, "no proof image found for this score")
|
||||||
|
}
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("load proof image: %v", err))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(proofImageData) == "" {
|
||||||
|
return writeError(c, http.StatusBadRequest, "no proof image found for this score")
|
||||||
|
}
|
||||||
|
|
||||||
|
currentScore, _ := a.loadCurrentScore(stage, playerID)
|
||||||
|
|
||||||
|
advice, err := a.generateScoreAdvice(stage, playerID, proofImageData, currentScore)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadGateway, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, advice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loadScoreProofImage(stage string, playerID int) (string, error) {
|
||||||
|
var imageData string
|
||||||
|
err := a.db.QueryRow(`SELECT image_data FROM score_attachments WHERE stage = ? AND player_id = ?`, stage, playerID).Scan(&imageData)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return imageData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loadCurrentScore(stage string, playerID int) (int, error) {
|
||||||
|
var score int
|
||||||
|
err := a.db.QueryRow(`SELECT score FROM scores WHERE stage = ? AND player_id = ?`, stage, playerID).Scan(&score)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return score, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) buildAdviceResponse(stage string, playerID int, raw scoreAdviceModelResponse) ScoreAdviceResponse {
|
||||||
|
advised := clampInt(raw.AdvisedScore, 0, 9999)
|
||||||
|
reason := strings.TrimSpace(raw.Reason)
|
||||||
|
if reason == "" {
|
||||||
|
reason = "AI estimated the score from visible impacts."
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScoreAdviceResponse{
|
||||||
|
Stage: stage,
|
||||||
|
PlayerID: playerID,
|
||||||
|
AdvisedScore: advised,
|
||||||
|
Reason: reason,
|
||||||
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampInt(value, min, max int) int {
|
||||||
|
if value < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if value > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
93
backend/auth.go
Normal file
93
backend/auth.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) isAdminRequest(c echo.Context) bool {
|
||||||
|
header := strings.TrimSpace(c.Request().Header.Get(echo.HeaderAuthorization))
|
||||||
|
if header == "" || !strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(header[7:])
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return a.sessions.ValidateToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) adminOnly(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
a.sessions.PurgeExpired()
|
||||||
|
|
||||||
|
header := strings.TrimSpace(c.Request().Header.Get(echo.HeaderAuthorization))
|
||||||
|
if header == "" || !strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
||||||
|
return writeError(c, http.StatusUnauthorized, "missing admin token")
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(header[7:])
|
||||||
|
if token == "" || !a.sessions.ValidateToken(token) {
|
||||||
|
return writeError(c, http.StatusUnauthorized, "invalid or expired admin token")
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) verifyAdmin(username, password string) bool {
|
||||||
|
u := strings.TrimSpace(username)
|
||||||
|
p := strings.TrimSpace(password)
|
||||||
|
if u == "" || p == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
userMatch := subtle.ConstantTimeCompare([]byte(u), []byte(a.cfg.AdminUser)) == 1
|
||||||
|
passMatch := subtle.ConstantTimeCompare([]byte(p), []byte(a.cfg.AdminPass)) == 1
|
||||||
|
return userMatch && passMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) CreateToken() (string, time.Time, error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
token := hex.EncodeToString(raw)
|
||||||
|
expires := time.Now().UTC().Add(s.duration)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.tokens[token] = expires
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return token, expires, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) ValidateToken(token string) bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
expires, ok := s.tokens[token]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().UTC().Before(expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) DeleteToken(token string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.tokens, token)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) PurgeExpired() {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
s.mu.Lock()
|
||||||
|
for token, expiry := range s.tokens {
|
||||||
|
if now.After(expiry) {
|
||||||
|
delete(s.tokens, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
36
backend/config.go
Normal file
36
backend/config.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
DBPath string
|
||||||
|
WebDir string
|
||||||
|
AdminUser string
|
||||||
|
AdminPass string
|
||||||
|
GeminiAPIKey string
|
||||||
|
GeminiModel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Port: envOrDefault("PORT", "8080"),
|
||||||
|
DBPath: envOrDefault("DB_PATH", "./data/shooting.db"),
|
||||||
|
WebDir: envOrDefault("WEB_DIR", "./web"),
|
||||||
|
AdminUser: envOrDefault("ADMIN_USER", "datwyler"),
|
||||||
|
AdminPass: envOrDefault("ADMIN_PASS", "datwyler"),
|
||||||
|
GeminiAPIKey: envOrDefault("GEMINI_API_KEY", "AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc"),
|
||||||
|
GeminiModel: envOrDefault("GEMINI_MODEL", "gemini-3.1-flash-lite-preview"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOrDefault(key, fallback string) string {
|
||||||
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
99
backend/db.go
Normal file
99
backend/db.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func initDB(cfg Config) (*sql.DB, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create data dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA busy_timeout = 5000;
|
||||||
|
`); err != nil {
|
||||||
|
return nil, fmt.Errorf("sqlite pragmas: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS players (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name_ar TEXT NOT NULL,
|
||||||
|
name_en TEXT NOT NULL,
|
||||||
|
group_code TEXT NOT NULL DEFAULT '',
|
||||||
|
image_data TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scores (
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY(stage, player_id),
|
||||||
|
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS score_attachments (
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
image_data TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY(stage, player_id),
|
||||||
|
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO app_settings(key, value) VALUES
|
||||||
|
('view_proof_in_view', '0'),
|
||||||
|
('live_active_view', 'group_live'),
|
||||||
|
('live_show_group_live', '1'),
|
||||||
|
('live_group_display_mode', 'rotate'),
|
||||||
|
('live_group_fixed_code', ''),
|
||||||
|
('live_show_prelim_tie', '1'),
|
||||||
|
('live_show_prelim_overall', '1'),
|
||||||
|
('live_show_final_groups', '1'),
|
||||||
|
('live_final_display_mode', 'rotate'),
|
||||||
|
('live_final_fixed_group', '1'),
|
||||||
|
('live_show_final_tie', '1'),
|
||||||
|
('live_show_podium', '1'),
|
||||||
|
('live_rotation_interval_sec', '5'),
|
||||||
|
('live_rotation_player_count', '12');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO scores(stage, player_id, score)
|
||||||
|
SELECT 'prelim_r1', player_id, score FROM scores WHERE stage = 'preliminary';
|
||||||
|
INSERT OR IGNORE INTO scores(stage, player_id, score)
|
||||||
|
SELECT 'final_r1', player_id, score FROM scores WHERE stage = 'final';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
|
||||||
|
SELECT 'prelim_r1', player_id, image_data FROM score_attachments WHERE stage = 'preliminary';
|
||||||
|
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
|
||||||
|
SELECT 'final_r1', player_id, image_data FROM score_attachments WHERE stage = 'final';
|
||||||
|
`
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
return nil, fmt.Errorf("apply schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
83
backend/events.go
Normal file
83
backend/events.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) handleEvents(c echo.Context) error {
|
||||||
|
res := c.Response()
|
||||||
|
req := c.Request()
|
||||||
|
|
||||||
|
res.Header().Set(echo.HeaderContentType, "text/event-stream")
|
||||||
|
res.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||||
|
res.Header().Set("Connection", "keep-alive")
|
||||||
|
res.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
res.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
flusher, ok := res.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "streaming not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprintf(res, "event: ready\ndata: {\"ts\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
id, events := a.events.Subscribe()
|
||||||
|
defer a.events.Unsubscribe(id)
|
||||||
|
|
||||||
|
keepAlive := time.NewTicker(20 * time.Second)
|
||||||
|
defer keepAlive.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-req.Context().Done():
|
||||||
|
return nil
|
||||||
|
case <-events:
|
||||||
|
if _, err := fmt.Fprintf(res, "event: state\ndata: {\"ts\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
case t := <-keepAlive.C:
|
||||||
|
if _, err := fmt.Fprintf(res, ": ping %d\n\n", t.Unix()); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EventHub) Subscribe() (int, <-chan struct{}) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.nextID++
|
||||||
|
id := h.nextID
|
||||||
|
ch := make(chan struct{}, 1)
|
||||||
|
h.subscribers[id] = ch
|
||||||
|
return id, ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EventHub) Unsubscribe(id int) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if ch, ok := h.subscribers[id]; ok {
|
||||||
|
delete(h.subscribers, id)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EventHub) Broadcast() {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for _, ch := range h.subscribers {
|
||||||
|
select {
|
||||||
|
case ch <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
203
backend/gemini.go
Normal file
203
backend/gemini.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type scoreAdviceModelResponse struct {
|
||||||
|
AdvisedScore int `json:"advisedScore"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiGenerateRequest struct {
|
||||||
|
Contents []geminiContent `json:"contents"`
|
||||||
|
GenerationConfig geminiGenerationConfig `json:"generationConfig,omitempty"`
|
||||||
|
SafetySettings []map[string]interface{} `json:"safetySettings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiContent struct {
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Parts []geminiPart `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiPart struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
InlineData *geminiInlineData `json:"inline_data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiInlineData struct {
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiGenerationConfig struct {
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
ResponseMimeType string `json:"responseMimeType,omitempty"`
|
||||||
|
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiGenerateResponse struct {
|
||||||
|
Candidates []struct {
|
||||||
|
Content struct {
|
||||||
|
Parts []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"parts"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"candidates"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) generateScoreAdvice(stage string, playerID int, imageData string, currentScore int) (ScoreAdviceResponse, error) {
|
||||||
|
mimeType, rawBase64, err := parseDataURI(imageData)
|
||||||
|
if err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("invalid proof image data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model := strings.TrimSpace(a.cfg.GeminiModel)
|
||||||
|
if model == "" {
|
||||||
|
model = "gemini-2.0-flash"
|
||||||
|
}
|
||||||
|
|
||||||
|
requestPayload := geminiGenerateRequest{
|
||||||
|
Contents: []geminiContent{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Parts: []geminiPart{
|
||||||
|
{Text: scoreAdvicePrompt(stage, currentScore)},
|
||||||
|
{
|
||||||
|
InlineData: &geminiInlineData{
|
||||||
|
MimeType: mimeType,
|
||||||
|
Data: rawBase64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GenerationConfig: geminiGenerationConfig{
|
||||||
|
Temperature: 0,
|
||||||
|
ResponseMimeType: "application/json",
|
||||||
|
MaxOutputTokens: 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(requestPayload)
|
||||||
|
if err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("marshal gemini request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s",
|
||||||
|
url.PathEscape(model),
|
||||||
|
url.QueryEscape(a.cfg.GeminiAPIKey),
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("create gemini request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 25 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("call gemini api: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2_000_000))
|
||||||
|
if err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("read gemini response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("gemini api status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var generated geminiGenerateResponse
|
||||||
|
if err := json.Unmarshal(respBody, &generated); err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("decode gemini response: %w", err)
|
||||||
|
}
|
||||||
|
if generated.Error != nil && strings.TrimSpace(generated.Error.Message) != "" {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("gemini api error: %s", generated.Error.Message)
|
||||||
|
}
|
||||||
|
if len(generated.Candidates) == 0 || len(generated.Candidates[0].Content.Parts) == 0 {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("gemini returned no advice")
|
||||||
|
}
|
||||||
|
|
||||||
|
rawText := strings.TrimSpace(generated.Candidates[0].Content.Parts[0].Text)
|
||||||
|
jsonText := extractJSONObject(rawText)
|
||||||
|
if jsonText == "" {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("gemini response was not valid json")
|
||||||
|
}
|
||||||
|
|
||||||
|
var modelResponse scoreAdviceModelResponse
|
||||||
|
if err := json.Unmarshal([]byte(jsonText), &modelResponse); err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("parse gemini advice json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.buildAdviceResponse(stage, playerID, modelResponse), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDataURI(dataURI string) (string, string, error) {
|
||||||
|
value := strings.TrimSpace(dataURI)
|
||||||
|
if !strings.HasPrefix(value, "data:") {
|
||||||
|
return "", "", fmt.Errorf("expected data uri")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(value, ",", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", "", fmt.Errorf("invalid data uri format")
|
||||||
|
}
|
||||||
|
|
||||||
|
header := parts[0]
|
||||||
|
payload := strings.TrimSpace(parts[1])
|
||||||
|
if payload == "" {
|
||||||
|
return "", "", fmt.Errorf("empty data uri payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeType := "image/jpeg"
|
||||||
|
if semicolon := strings.Index(header, ";"); semicolon > len("data:") {
|
||||||
|
mimeType = strings.TrimSpace(header[len("data:"):semicolon])
|
||||||
|
}
|
||||||
|
if mimeType == "" {
|
||||||
|
mimeType = "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
return mimeType, payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractJSONObject(raw string) string {
|
||||||
|
text := strings.TrimSpace(raw)
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(text, "```") {
|
||||||
|
text = strings.TrimPrefix(text, "```json")
|
||||||
|
text = strings.TrimPrefix(text, "```")
|
||||||
|
text = strings.TrimSuffix(strings.TrimSpace(text), "```")
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
start := strings.Index(text, "{")
|
||||||
|
end := strings.LastIndex(text, "}")
|
||||||
|
if start == -1 || end == -1 || end <= start {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(text[start : end+1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreAdvicePrompt(stage string, currentScore int) string {
|
||||||
|
return fmt.Sprintf(`Target scoring assistant.
|
||||||
|
Stage: %s. Current score: %d.
|
||||||
|
Return STRICT JSON only:
|
||||||
|
{"advisedScore":<int 0..9999>,"reason":"<max 18 words>"}
|
||||||
|
Do not add markdown or extra fields.`, stage, currentScore)
|
||||||
|
}
|
||||||
29
backend/go.mod
Normal file
29
backend/go.mod
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
module shooting-event/backend
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/labstack/echo/v4 v4.13.4
|
||||||
|
modernc.org/sqlite v1.39.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
golang.org/x/crypto v0.38.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/net v0.40.0 // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.25.0 // indirect
|
||||||
|
golang.org/x/time v0.11.0 // indirect
|
||||||
|
modernc.org/libc v1.66.10 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
75
backend/go.sum
Normal file
75
backend/go.sum
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||||
|
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
|
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||||
|
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||||
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
|
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
|
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
|
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
|
||||||
|
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
492
backend/handlers.go
Normal file
492
backend/handlers.go
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) handleHealth(c echo.Context) error {
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleGetState(c echo.Context) error {
|
||||||
|
state, err := a.readState(a.isAdminRequest(c))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleAdminLogin(c echo.Context) error {
|
||||||
|
var req AdminLoginRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.verifyAdmin(req.Username, req.Password) {
|
||||||
|
return writeError(c, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, expiry, err := a.sessions.CreateToken()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to create session")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{
|
||||||
|
"token": token,
|
||||||
|
"expiresAt": expiry.Format(time.RFC3339),
|
||||||
|
"username": a.cfg.AdminUser,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleAdminLogout(c echo.Context) error {
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(c.Request().Header.Get(echo.HeaderAuthorization), "Bearer "))
|
||||||
|
a.sessions.DeleteToken(token)
|
||||||
|
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleUpdateAdminSettings(c echo.Context) error {
|
||||||
|
var req AdminSettingsUpdateRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
if req.ViewProofInView == nil && req.LiveMode == nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "at least one settings field is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.updateSettings(req); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "no settings to update") {
|
||||||
|
return writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleCreatePlayer(c echo.Context) error {
|
||||||
|
var req PlayerCreateRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
nameAr := strings.TrimSpace(req.NameAr)
|
||||||
|
nameEn := strings.TrimSpace(req.NameEn)
|
||||||
|
group := normalizeGroup(req.GroupCode)
|
||||||
|
if nameAr == "" || nameEn == "" {
|
||||||
|
return writeError(c, http.StatusBadRequest, "both Arabic and English names are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := a.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to start transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
res, err := tx.Exec(`
|
||||||
|
INSERT INTO players(name_ar, name_en, group_code, image_data)
|
||||||
|
VALUES(?, ?, ?, '')
|
||||||
|
`, nameAr, nameEn, group)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("create player: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
id64, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to read new player id")
|
||||||
|
}
|
||||||
|
playerID := int(id64)
|
||||||
|
|
||||||
|
for _, stage := range scoreStages {
|
||||||
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO scores(stage, player_id, score) VALUES(?, ?, 0)`, stage, playerID); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("create score row: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to commit create player")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusCreated, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleUpdatePlayer(c echo.Context) error {
|
||||||
|
playerID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req PlayerUpdateRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := []string{}
|
||||||
|
args := []any{}
|
||||||
|
|
||||||
|
if req.NameAr != nil {
|
||||||
|
nameAr := strings.TrimSpace(*req.NameAr)
|
||||||
|
if nameAr == "" {
|
||||||
|
return writeError(c, http.StatusBadRequest, "arabic name cannot be empty")
|
||||||
|
}
|
||||||
|
updates = append(updates, "name_ar = ?")
|
||||||
|
args = append(args, nameAr)
|
||||||
|
}
|
||||||
|
if req.NameEn != nil {
|
||||||
|
nameEn := strings.TrimSpace(*req.NameEn)
|
||||||
|
if nameEn == "" {
|
||||||
|
return writeError(c, http.StatusBadRequest, "english name cannot be empty")
|
||||||
|
}
|
||||||
|
updates = append(updates, "name_en = ?")
|
||||||
|
args = append(args, nameEn)
|
||||||
|
}
|
||||||
|
if req.GroupCode != nil {
|
||||||
|
updates = append(updates, "group_code = ?")
|
||||||
|
args = append(args, normalizeGroup(*req.GroupCode))
|
||||||
|
}
|
||||||
|
if req.ImageData != nil {
|
||||||
|
img := strings.TrimSpace(*req.ImageData)
|
||||||
|
if len(img) > 2_500_000 {
|
||||||
|
return writeError(c, http.StatusBadRequest, "image payload too large")
|
||||||
|
}
|
||||||
|
updates = append(updates, "image_data = ?")
|
||||||
|
args = append(args, img)
|
||||||
|
}
|
||||||
|
if req.ClearImage {
|
||||||
|
updates = append(updates, "image_data = ''")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return writeError(c, http.StatusBadRequest, "no fields to update")
|
||||||
|
}
|
||||||
|
|
||||||
|
updates = append(updates, "updated_at = CURRENT_TIMESTAMP")
|
||||||
|
args = append(args, playerID)
|
||||||
|
|
||||||
|
query := fmt.Sprintf("UPDATE players SET %s WHERE id = ?", strings.Join(updates, ", "))
|
||||||
|
res, err := a.db.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update player: %v", err))
|
||||||
|
}
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to check update result")
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return writeError(c, http.StatusNotFound, "player not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleDeletePlayer(c echo.Context) error {
|
||||||
|
playerID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := a.db.Exec(`DELETE FROM players WHERE id = ?`, playerID)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("delete player: %v", err))
|
||||||
|
}
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to check delete result")
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return writeError(c, http.StatusNotFound, "player not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleAutoGroupPlayers(c echo.Context) error {
|
||||||
|
var req AutoGroupPlayersRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := normalizeGroups(req.Groups)
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return writeError(c, http.StatusBadRequest, "at least one group is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := a.db.Query(`SELECT id FROM players ORDER BY id ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("query players: %v", err))
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
playerIDs := []int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("scan player: %v", err))
|
||||||
|
}
|
||||||
|
playerIDs = append(playerIDs, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("read players: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
rng.Shuffle(len(playerIDs), func(i, j int) {
|
||||||
|
playerIDs[i], playerIDs[j] = playerIDs[j], playerIDs[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
tx, err := a.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to start transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for i, playerID := range playerIDs {
|
||||||
|
groupCode := groups[i%len(groups)]
|
||||||
|
if _, err := tx.Exec(`UPDATE players SET group_code = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, groupCode, playerID); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("assign player group: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to commit group assignment")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleUpdateScore(c echo.Context) error {
|
||||||
|
stage, err := validateStage(c.Param("stage"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ScoreUpdateRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
if req.Score < 0 || req.Score > 9999 {
|
||||||
|
return writeError(c, http.StatusBadRequest, "score must be between 0 and 9999")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := a.db.Exec(`
|
||||||
|
INSERT INTO scores(stage, player_id, score)
|
||||||
|
VALUES(?, ?, ?)
|
||||||
|
ON CONFLICT(stage, player_id) DO UPDATE SET score = excluded.score, updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, stage, playerID, req.Score)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update score: %v", err))
|
||||||
|
}
|
||||||
|
if _, err := res.RowsAffected(); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to check score update")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleResetStageScores(c echo.Context) error {
|
||||||
|
targetStages, err := resolveResetStages(c.Param("stage"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ResetStageRequest
|
||||||
|
if err := c.Bind(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := a.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to start reset transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for _, stage := range targetStages {
|
||||||
|
if _, err := tx.Exec(`UPDATE scores SET score = 0, updated_at = CURRENT_TIMESTAMP WHERE stage = ?`, stage); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage scores: %v", err))
|
||||||
|
}
|
||||||
|
if req.ResetProofs {
|
||||||
|
if _, err := tx.Exec(`DELETE FROM score_attachments WHERE stage = ?`, stage); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage proofs: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("commit stage reset: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveResetStages(stage string) ([]string, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(stage)) {
|
||||||
|
case "preliminary":
|
||||||
|
return append([]string{}, preliminaryRoundStages...), nil
|
||||||
|
case "final":
|
||||||
|
return append([]string{}, finalRoundStages...), nil
|
||||||
|
case "prelim_tiebreak", "final_tiebreak":
|
||||||
|
normalized, err := validateStage(stage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []string{normalized}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid stage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleUpdateScoreProof(c echo.Context) error {
|
||||||
|
stage, err := validateStage(c.Param("stage"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ScoreProofUpdateRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
imageData := strings.TrimSpace(req.ImageData)
|
||||||
|
if imageData == "" {
|
||||||
|
return writeError(c, http.StatusBadRequest, "imageData is required")
|
||||||
|
}
|
||||||
|
if len(imageData) > 5_000_000 {
|
||||||
|
return writeError(c, http.StatusBadRequest, "image payload too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.db.Exec(`
|
||||||
|
INSERT INTO score_attachments(stage, player_id, image_data)
|
||||||
|
VALUES(?, ?, ?)
|
||||||
|
ON CONFLICT(stage, player_id) DO UPDATE SET image_data = excluded.image_data, updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, stage, playerID, imageData); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update score proof: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleDeleteScoreProof(c echo.Context) error {
|
||||||
|
stage, err := validateStage(c.Param("stage"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.db.Exec(`DELETE FROM score_attachments WHERE stage = ? AND player_id = ?`, stage, playerID); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("delete score proof: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateStage(stage string) (string, error) {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(stage))
|
||||||
|
for _, allowed := range scoreStages {
|
||||||
|
if normalized == allowed {
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("invalid stage")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGroup(group string) string {
|
||||||
|
return strings.TrimSpace(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGroups(groups []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []string{}
|
||||||
|
for _, group := range groups {
|
||||||
|
normalized := normalizeGroup(group)
|
||||||
|
if normalized == "" || seen[normalized] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[normalized] = true
|
||||||
|
out = append(out, normalized)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(c echo.Context, status int, message string) error {
|
||||||
|
return c.JSON(status, map[string]string{"message": message})
|
||||||
|
}
|
||||||
54
backend/main.go
Normal file
54
backend/main.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
db, err := initDB(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("init db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
app := &App{
|
||||||
|
db: db,
|
||||||
|
cfg: cfg,
|
||||||
|
sessions: &SessionStore{
|
||||||
|
tokens: map[string]time.Time{},
|
||||||
|
duration: 8 * time.Hour,
|
||||||
|
},
|
||||||
|
events: &EventHub{
|
||||||
|
subscribers: map[int]chan struct{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
e.HideBanner = true
|
||||||
|
e.HidePort = true
|
||||||
|
e.Use(middleware.Recover())
|
||||||
|
e.Use(middleware.Logger())
|
||||||
|
e.Use(middleware.RequestID())
|
||||||
|
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
|
AllowOrigins: []string{"*"},
|
||||||
|
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions},
|
||||||
|
AllowHeaders: []string{echo.HeaderContentType, echo.HeaderAuthorization},
|
||||||
|
}))
|
||||||
|
|
||||||
|
registerAPIRoutes(e, app)
|
||||||
|
registerWebRoutes(e, cfg)
|
||||||
|
|
||||||
|
addr := ":" + cfg.Port
|
||||||
|
log.Printf("listening on %s", addr)
|
||||||
|
if err := e.Start(addr); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
179
backend/models.go
Normal file
179
backend/models.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var preliminaryRoundStages = []string{"prelim_r1", "prelim_r2", "prelim_r3"}
|
||||||
|
var finalRoundStages = []string{"final_r1", "final_r2"}
|
||||||
|
var tieBreakStages = []string{"prelim_tiebreak", "final_tiebreak"}
|
||||||
|
|
||||||
|
var scoreStages = append(append(append([]string{}, preliminaryRoundStages...), finalRoundStages...), tieBreakStages...)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
db *sql.DB
|
||||||
|
cfg Config
|
||||||
|
sessions *SessionStore
|
||||||
|
events *EventHub
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
tokens map[string]time.Time
|
||||||
|
duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventHub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
nextID int
|
||||||
|
subscribers map[int]chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompetitionMeta struct {
|
||||||
|
TitleAr string `json:"titleAr"`
|
||||||
|
TitleEn string `json:"titleEn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Player struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
NameAr string `json:"nameAr"`
|
||||||
|
NameEn string `json:"nameEn"`
|
||||||
|
GroupCode string `json:"groupCode"`
|
||||||
|
ImageData string `json:"imageData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RankingRow struct {
|
||||||
|
PlayerID int `json:"playerId"`
|
||||||
|
NameAr string `json:"nameAr"`
|
||||||
|
NameEn string `json:"nameEn"`
|
||||||
|
GroupCode string `json:"groupCode"`
|
||||||
|
ImageData string `json:"imageData"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
TieBreak int `json:"tieBreak"`
|
||||||
|
Rank int `json:"rank"`
|
||||||
|
Seed int `json:"seed"`
|
||||||
|
FinalGroup int `json:"finalGroup"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TieBreakInfo struct {
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Resolved bool `json:"resolved"`
|
||||||
|
Slots int `json:"slots"`
|
||||||
|
PlayerIDs []int `json:"playerIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DerivedState struct {
|
||||||
|
PreliminaryRanking RankingBundle `json:"preliminaryRanking"`
|
||||||
|
Finalists []RankingRow `json:"finalists"`
|
||||||
|
FinalGroups FinalGroups `json:"finalGroups"`
|
||||||
|
FinalRanking RankingBundle `json:"finalRanking"`
|
||||||
|
Podium []RankingRow `json:"podium"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RankingBundle struct {
|
||||||
|
Rows []RankingRow `json:"rows"`
|
||||||
|
TieBreak TieBreakInfo `json:"tieBreak"`
|
||||||
|
Unresolved bool `json:"unresolved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinalGroups struct {
|
||||||
|
Group1 []RankingRow `json:"group1"`
|
||||||
|
Group2 []RankingRow `json:"group2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateResponse struct {
|
||||||
|
Competition CompetitionMeta `json:"competition"`
|
||||||
|
Players []Player `json:"players"`
|
||||||
|
Scores map[string]map[string]int `json:"scores"`
|
||||||
|
ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"`
|
||||||
|
Settings AppSettings `json:"settings"`
|
||||||
|
Derived DerivedState `json:"derived"`
|
||||||
|
ServerTime string `json:"serverTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppSettings struct {
|
||||||
|
ViewProofInView bool `json:"viewProofInView"`
|
||||||
|
LiveMode LiveModeSettings `json:"liveMode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveModeSettings struct {
|
||||||
|
ActiveView string `json:"activeView"`
|
||||||
|
ShowGroupLive bool `json:"showGroupLive"`
|
||||||
|
GroupDisplayMode string `json:"groupDisplayMode"`
|
||||||
|
GroupFixedCode string `json:"groupFixedCode"`
|
||||||
|
ShowPrelimTie bool `json:"showPrelimTie"`
|
||||||
|
ShowPrelimOverall bool `json:"showPrelimOverall"`
|
||||||
|
ShowFinalGroups bool `json:"showFinalGroups"`
|
||||||
|
FinalDisplayMode string `json:"finalDisplayMode"`
|
||||||
|
FinalFixedGroup int `json:"finalFixedGroup"`
|
||||||
|
ShowFinalTie bool `json:"showFinalTie"`
|
||||||
|
ShowPodium bool `json:"showPodium"`
|
||||||
|
RotationIntervalSec int `json:"rotationIntervalSec"`
|
||||||
|
RotationPlayerCount int `json:"rotationPlayerCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminLoginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerCreateRequest struct {
|
||||||
|
NameAr string `json:"nameAr"`
|
||||||
|
NameEn string `json:"nameEn"`
|
||||||
|
GroupCode string `json:"groupCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerUpdateRequest struct {
|
||||||
|
NameAr *string `json:"nameAr"`
|
||||||
|
NameEn *string `json:"nameEn"`
|
||||||
|
GroupCode *string `json:"groupCode"`
|
||||||
|
ImageData *string `json:"imageData"`
|
||||||
|
ClearImage bool `json:"clearImage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreUpdateRequest struct {
|
||||||
|
Score int `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreProofUpdateRequest struct {
|
||||||
|
ImageData string `json:"imageData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminSettingsUpdateRequest struct {
|
||||||
|
ViewProofInView *bool `json:"viewProofInView"`
|
||||||
|
LiveMode *LiveModeSettingsUpdateRequest `json:"liveMode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveModeSettingsUpdateRequest struct {
|
||||||
|
ActiveView *string `json:"activeView"`
|
||||||
|
ShowGroupLive *bool `json:"showGroupLive"`
|
||||||
|
GroupDisplayMode *string `json:"groupDisplayMode"`
|
||||||
|
GroupFixedCode *string `json:"groupFixedCode"`
|
||||||
|
ShowPrelimTie *bool `json:"showPrelimTie"`
|
||||||
|
ShowPrelimOverall *bool `json:"showPrelimOverall"`
|
||||||
|
ShowFinalGroups *bool `json:"showFinalGroups"`
|
||||||
|
FinalDisplayMode *string `json:"finalDisplayMode"`
|
||||||
|
FinalFixedGroup *int `json:"finalFixedGroup"`
|
||||||
|
ShowFinalTie *bool `json:"showFinalTie"`
|
||||||
|
ShowPodium *bool `json:"showPodium"`
|
||||||
|
RotationIntervalSec *int `json:"rotationIntervalSec"`
|
||||||
|
RotationPlayerCount *int `json:"rotationPlayerCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResetStageRequest struct {
|
||||||
|
ResetProofs bool `json:"resetProofs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoGroupPlayersRequest struct {
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreAdviceResponse struct {
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
PlayerID int `json:"playerId"`
|
||||||
|
AdvisedScore int `json:"advisedScore"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
GeneratedAt string `json:"generatedAt"`
|
||||||
|
}
|
||||||
57
backend/routes.go
Normal file
57
backend/routes.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerAPIRoutes(e *echo.Echo, app *App) {
|
||||||
|
api := e.Group("/api")
|
||||||
|
api.GET("/health", app.handleHealth)
|
||||||
|
api.GET("/state", app.handleGetState)
|
||||||
|
api.GET("/events", app.handleEvents)
|
||||||
|
|
||||||
|
api.POST("/admin/login", app.handleAdminLogin)
|
||||||
|
api.POST("/admin/logout", app.handleAdminLogout, app.adminOnly)
|
||||||
|
api.GET("/admin/state", app.handleGetState, app.adminOnly)
|
||||||
|
api.PUT("/admin/settings", app.handleUpdateAdminSettings, app.adminOnly)
|
||||||
|
api.POST("/admin/players", app.handleCreatePlayer, app.adminOnly)
|
||||||
|
api.POST("/admin/players/auto-group", app.handleAutoGroupPlayers, app.adminOnly)
|
||||||
|
api.PUT("/admin/players/:id", app.handleUpdatePlayer, app.adminOnly)
|
||||||
|
api.DELETE("/admin/players/:id", app.handleDeletePlayer, app.adminOnly)
|
||||||
|
api.PUT("/admin/scores/:stage/:id", app.handleUpdateScore, app.adminOnly)
|
||||||
|
api.PUT("/admin/scores/:stage/:id/proof", app.handleUpdateScoreProof, app.adminOnly)
|
||||||
|
api.DELETE("/admin/scores/:stage/:id/proof", app.handleDeleteScoreProof, app.adminOnly)
|
||||||
|
api.POST("/admin/scores/:stage/:id/advice", app.handleScoreAdvice, app.adminOnly)
|
||||||
|
api.POST("/admin/scores/:stage/reset", app.handleResetStageScores, app.adminOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerWebRoutes(e *echo.Echo, cfg Config) {
|
||||||
|
e.GET("/*", func(c echo.Context) error {
|
||||||
|
requestPath := strings.TrimPrefix(c.Param("*"), "/")
|
||||||
|
if strings.HasPrefix(requestPath, "api") {
|
||||||
|
return echo.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestPath == "" {
|
||||||
|
requestPath = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
clean := strings.TrimPrefix(filepath.Clean("/"+requestPath), "/")
|
||||||
|
assetPath := filepath.Join(cfg.WebDir, clean)
|
||||||
|
if stat, err := os.Stat(assetPath); err == nil && !stat.IsDir() {
|
||||||
|
return c.File(assetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
indexPath := filepath.Join(cfg.WebDir, "index.html")
|
||||||
|
if stat, err := os.Stat(indexPath); err == nil && !stat.IsDir() {
|
||||||
|
return c.File(indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.String(http.StatusNotFound, "frontend build not found. run make build")
|
||||||
|
})
|
||||||
|
}
|
||||||
366
backend/settings.go
Normal file
366
backend/settings.go
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
settingViewProofInView = "view_proof_in_view"
|
||||||
|
settingLiveActiveView = "live_active_view"
|
||||||
|
settingLiveShowGroupLive = "live_show_group_live"
|
||||||
|
settingLiveGroupDisplayMode = "live_group_display_mode"
|
||||||
|
settingLiveGroupFixedCode = "live_group_fixed_code"
|
||||||
|
settingLiveShowPrelimTie = "live_show_prelim_tie"
|
||||||
|
settingLiveShowPrelimOverall = "live_show_prelim_overall"
|
||||||
|
settingLiveShowFinalGroups = "live_show_final_groups"
|
||||||
|
settingLiveFinalDisplayMode = "live_final_display_mode"
|
||||||
|
settingLiveFinalFixedGroup = "live_final_fixed_group"
|
||||||
|
settingLiveShowFinalTie = "live_show_final_tie"
|
||||||
|
settingLiveShowPodium = "live_show_podium"
|
||||||
|
settingLiveRotationInterval = "live_rotation_interval_sec"
|
||||||
|
settingLiveRotationPlayers = "live_rotation_player_count"
|
||||||
|
)
|
||||||
|
|
||||||
|
func defaultLiveModeSettings() LiveModeSettings {
|
||||||
|
return LiveModeSettings{
|
||||||
|
ActiveView: "group_live",
|
||||||
|
ShowGroupLive: true,
|
||||||
|
GroupDisplayMode: "rotate",
|
||||||
|
GroupFixedCode: "",
|
||||||
|
ShowPrelimTie: true,
|
||||||
|
ShowPrelimOverall: true,
|
||||||
|
ShowFinalGroups: true,
|
||||||
|
FinalDisplayMode: "rotate",
|
||||||
|
FinalFixedGroup: 1,
|
||||||
|
ShowFinalTie: true,
|
||||||
|
ShowPodium: true,
|
||||||
|
RotationIntervalSec: 5,
|
||||||
|
RotationPlayerCount: 12,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readSettings() (AppSettings, error) {
|
||||||
|
live := defaultLiveModeSettings()
|
||||||
|
|
||||||
|
viewProofRaw, err := a.readSettingValue(settingViewProofInView)
|
||||||
|
if err != nil {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingViewProofInView, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveShowGroupLive); err == nil {
|
||||||
|
live.ShowGroupLive = settingBool(raw)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowGroupLive, err)
|
||||||
|
}
|
||||||
|
if raw, err := a.readSettingValue(settingLiveActiveView); err == nil {
|
||||||
|
live.ActiveView = normalizeLiveActiveView(raw, live.ActiveView)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveActiveView, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveGroupDisplayMode); err == nil {
|
||||||
|
live.GroupDisplayMode = normalizeLiveDisplayMode(raw, live.GroupDisplayMode)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveGroupDisplayMode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveGroupFixedCode); err == nil {
|
||||||
|
live.GroupFixedCode = strings.ToUpper(strings.TrimSpace(raw))
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveGroupFixedCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveShowPrelimTie); err == nil {
|
||||||
|
live.ShowPrelimTie = settingBool(raw)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowPrelimTie, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveShowPrelimOverall); err == nil {
|
||||||
|
live.ShowPrelimOverall = settingBool(raw)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowPrelimOverall, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveShowFinalGroups); err == nil {
|
||||||
|
live.ShowFinalGroups = settingBool(raw)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowFinalGroups, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveFinalDisplayMode); err == nil {
|
||||||
|
live.FinalDisplayMode = normalizeLiveDisplayMode(raw, live.FinalDisplayMode)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveFinalDisplayMode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveFinalFixedGroup); err == nil {
|
||||||
|
live.FinalFixedGroup = normalizeLiveFinalFixedGroup(raw, live.FinalFixedGroup)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveFinalFixedGroup, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveShowFinalTie); err == nil {
|
||||||
|
live.ShowFinalTie = settingBool(raw)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowFinalTie, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveShowPodium); err == nil {
|
||||||
|
live.ShowPodium = settingBool(raw)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowPodium, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveRotationInterval); err == nil {
|
||||||
|
live.RotationIntervalSec = normalizeRotationInterval(raw, live.RotationIntervalSec)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveRotationInterval, err)
|
||||||
|
}
|
||||||
|
if raw, err := a.readSettingValue(settingLiveRotationPlayers); err == nil {
|
||||||
|
live.RotationPlayerCount = normalizeRotationPlayerCount(raw, live.RotationPlayerCount)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveRotationPlayers, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppSettings{
|
||||||
|
ViewProofInView: settingBool(viewProofRaw),
|
||||||
|
LiveMode: live,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateSettings(req AdminSettingsUpdateRequest) error {
|
||||||
|
tx, err := a.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin settings transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
hasUpdate := false
|
||||||
|
|
||||||
|
if req.ViewProofInView != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingViewProofInView, settingString(*req.ViewProofInView)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.LiveMode != nil {
|
||||||
|
patch := req.LiveMode
|
||||||
|
if patch.ActiveView != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := normalizeLiveActiveView(*patch.ActiveView, "group_live")
|
||||||
|
if err := upsertSetting(tx, settingLiveActiveView, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.ShowGroupLive != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingLiveShowGroupLive, settingString(*patch.ShowGroupLive)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.GroupDisplayMode != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := normalizeLiveDisplayMode(*patch.GroupDisplayMode, "rotate")
|
||||||
|
if err := upsertSetting(tx, settingLiveGroupDisplayMode, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.GroupFixedCode != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := strings.ToUpper(strings.TrimSpace(*patch.GroupFixedCode))
|
||||||
|
if err := upsertSetting(tx, settingLiveGroupFixedCode, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.ShowPrelimTie != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingLiveShowPrelimTie, settingString(*patch.ShowPrelimTie)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.ShowPrelimOverall != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingLiveShowPrelimOverall, settingString(*patch.ShowPrelimOverall)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.ShowFinalGroups != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingLiveShowFinalGroups, settingString(*patch.ShowFinalGroups)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.FinalDisplayMode != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := normalizeLiveDisplayMode(*patch.FinalDisplayMode, "rotate")
|
||||||
|
if err := upsertSetting(tx, settingLiveFinalDisplayMode, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.FinalFixedGroup != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := normalizeLiveFinalFixedGroupInt(*patch.FinalFixedGroup)
|
||||||
|
if err := upsertSetting(tx, settingLiveFinalFixedGroup, strconv.Itoa(value)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.ShowFinalTie != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingLiveShowFinalTie, settingString(*patch.ShowFinalTie)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.ShowPodium != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingLiveShowPodium, settingString(*patch.ShowPodium)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.RotationIntervalSec != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := normalizeRotationIntervalInt(*patch.RotationIntervalSec)
|
||||||
|
if err := upsertSetting(tx, settingLiveRotationInterval, strconv.Itoa(value)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.RotationPlayerCount != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := normalizeRotationPlayerCountInt(*patch.RotationPlayerCount)
|
||||||
|
if err := upsertSetting(tx, settingLiveRotationPlayers, strconv.Itoa(value)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasUpdate {
|
||||||
|
return fmt.Errorf("no settings to update")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit settings transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertSetting(tx *sql.Tx, key string, value string) error {
|
||||||
|
_, err := tx.Exec(`
|
||||||
|
INSERT INTO app_settings(key, value, updated_at)
|
||||||
|
VALUES(?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, key, value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update app setting %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readSettingValue(key string) (string, error) {
|
||||||
|
var raw string
|
||||||
|
err := a.db.QueryRow(`SELECT value FROM app_settings WHERE key = ?`, key).Scan(&raw)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingBool(value string) bool {
|
||||||
|
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingString(value bool) string {
|
||||||
|
if value {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLiveDisplayMode(value string, fallback string) string {
|
||||||
|
normalized := strings.TrimSpace(strings.ToLower(value))
|
||||||
|
if normalized == "fixed" {
|
||||||
|
return "fixed"
|
||||||
|
}
|
||||||
|
if normalized == "rotate" {
|
||||||
|
return "rotate"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(strings.ToLower(fallback)) == "fixed" {
|
||||||
|
return "fixed"
|
||||||
|
}
|
||||||
|
return "rotate"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLiveFinalFixedGroup(value string, fallback int) int {
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||||
|
if err != nil {
|
||||||
|
return normalizeLiveFinalFixedGroupInt(fallback)
|
||||||
|
}
|
||||||
|
return normalizeLiveFinalFixedGroupInt(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLiveFinalFixedGroupInt(value int) int {
|
||||||
|
if value == 2 {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRotationInterval(raw string, fallback int) int {
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return normalizeRotationIntervalInt(fallback)
|
||||||
|
}
|
||||||
|
return normalizeRotationIntervalInt(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRotationIntervalInt(value int) int {
|
||||||
|
if value < 3 {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
if value > 30 {
|
||||||
|
return 30
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRotationPlayerCount(raw string, fallback int) int {
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return normalizeRotationPlayerCountInt(fallback)
|
||||||
|
}
|
||||||
|
return normalizeRotationPlayerCountInt(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRotationPlayerCountInt(value int) int {
|
||||||
|
if value < 3 {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
if value > 40 {
|
||||||
|
return 40
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLiveActiveView(value string, fallback string) string {
|
||||||
|
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||||
|
case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "podium":
|
||||||
|
return strings.TrimSpace(strings.ToLower(value))
|
||||||
|
default:
|
||||||
|
if strings.TrimSpace(strings.ToLower(fallback)) != "" {
|
||||||
|
return strings.TrimSpace(strings.ToLower(fallback))
|
||||||
|
}
|
||||||
|
return "group_live"
|
||||||
|
}
|
||||||
|
}
|
||||||
494
backend/state.go
Normal file
494
backend/state.go
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
|
||||||
|
players, err := a.readPlayers()
|
||||||
|
if err != nil {
|
||||||
|
return StateResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.ensureScoreRows(players); err != nil {
|
||||||
|
return StateResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := a.readSettings()
|
||||||
|
if err != nil {
|
||||||
|
return StateResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scoreMap, err := a.readScores(players)
|
||||||
|
if err != nil {
|
||||||
|
return StateResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
includeScoreProofs := includeAllProofs || settings.ViewProofInView
|
||||||
|
var scoreProofs map[string]map[int]string
|
||||||
|
if includeScoreProofs {
|
||||||
|
scoreProofs, err = a.readScoreProofs(players)
|
||||||
|
if err != nil {
|
||||||
|
return StateResponse{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
derived := computeDerived(players, scoreMap)
|
||||||
|
|
||||||
|
response := StateResponse{
|
||||||
|
Competition: CompetitionMeta{
|
||||||
|
TitleAr: "بطولة دويتوايلر للرماية",
|
||||||
|
TitleEn: "Datwyler Shooting Event",
|
||||||
|
},
|
||||||
|
Players: players,
|
||||||
|
Scores: scoreMapToJSON(scoreMap),
|
||||||
|
Settings: settings,
|
||||||
|
Derived: derived,
|
||||||
|
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if includeScoreProofs {
|
||||||
|
response.ScoreProofs = scoreProofMapToJSON(scoreProofs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readPlayers() ([]Player, error) {
|
||||||
|
rows, err := a.db.Query(`
|
||||||
|
SELECT id, name_ar, name_en, group_code, image_data
|
||||||
|
FROM players
|
||||||
|
ORDER BY id ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query players: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
players := []Player{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Player
|
||||||
|
if err := rows.Scan(&p.ID, &p.NameAr, &p.NameEn, &p.GroupCode, &p.ImageData); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan player: %w", err)
|
||||||
|
}
|
||||||
|
players = append(players, p)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate players: %w", err)
|
||||||
|
}
|
||||||
|
return players, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readScoreProofs(players []Player) (map[string]map[int]string, error) {
|
||||||
|
proofs := map[string]map[int]string{}
|
||||||
|
for _, stage := range scoreStages {
|
||||||
|
proofs[stage] = map[int]string{}
|
||||||
|
}
|
||||||
|
for _, p := range players {
|
||||||
|
for _, stage := range scoreStages {
|
||||||
|
proofs[stage][p.ID] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := a.db.Query(`SELECT stage, player_id, image_data FROM score_attachments`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query score attachments: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var stage string
|
||||||
|
var playerID int
|
||||||
|
var imageData string
|
||||||
|
if err := rows.Scan(&stage, &playerID, &imageData); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan score attachment: %w", err)
|
||||||
|
}
|
||||||
|
stage = strings.ToLower(strings.TrimSpace(stage))
|
||||||
|
stageMap, ok := proofs[stage]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stageMap[playerID] = imageData
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate score attachments: %w", err)
|
||||||
|
}
|
||||||
|
return proofs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreProofMapToJSON(proofMap map[string]map[int]string) map[string]map[string]string {
|
||||||
|
out := map[string]map[string]string{}
|
||||||
|
for stage, stageMap := range proofMap {
|
||||||
|
out[stage] = map[string]string{}
|
||||||
|
for playerID, imageData := range stageMap {
|
||||||
|
if strings.TrimSpace(imageData) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[stage][strconv.Itoa(playerID)] = imageData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureScoreRows(players []Player) error {
|
||||||
|
if len(players) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := a.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin score row ensure tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for _, p := range players {
|
||||||
|
for _, stage := range scoreStages {
|
||||||
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO scores(stage, player_id, score) VALUES(?, ?, 0)`, stage, p.ID); err != nil {
|
||||||
|
return fmt.Errorf("ensure score row (%s,%d): %w", stage, p.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit score row ensure tx: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readScores(players []Player) (map[string]map[int]int, error) {
|
||||||
|
scores := map[string]map[int]int{}
|
||||||
|
for _, stage := range scoreStages {
|
||||||
|
scores[stage] = map[int]int{}
|
||||||
|
}
|
||||||
|
for _, p := range players {
|
||||||
|
for _, stage := range scoreStages {
|
||||||
|
scores[stage][p.ID] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := a.db.Query(`SELECT stage, player_id, score FROM scores`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query scores: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var stage string
|
||||||
|
var playerID int
|
||||||
|
var score int
|
||||||
|
if err := rows.Scan(&stage, &playerID, &score); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan score: %w", err)
|
||||||
|
}
|
||||||
|
stage = strings.ToLower(stage)
|
||||||
|
if _, ok := scores[stage]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scores[stage][playerID] = score
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate scores: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scores, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreMapToJSON(scoreMap map[string]map[int]int) map[string]map[string]int {
|
||||||
|
out := map[string]map[string]int{}
|
||||||
|
for stage, stageMap := range scoreMap {
|
||||||
|
out[stage] = map[string]int{}
|
||||||
|
for playerID, value := range stageMap {
|
||||||
|
out[stage][strconv.Itoa(playerID)] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeDerived(players []Player, scores map[string]map[int]int) DerivedState {
|
||||||
|
playerByID := map[int]Player{}
|
||||||
|
for _, p := range players {
|
||||||
|
playerByID[p.ID] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
preRows := []RankingRow{}
|
||||||
|
for _, p := range players {
|
||||||
|
preRows = append(preRows, RankingRow{
|
||||||
|
PlayerID: p.ID,
|
||||||
|
NameAr: p.NameAr,
|
||||||
|
NameEn: p.NameEn,
|
||||||
|
GroupCode: p.GroupCode,
|
||||||
|
ImageData: p.ImageData,
|
||||||
|
Score: scoreTotal(scores, p.ID, preliminaryRoundStages),
|
||||||
|
TieBreak: scores["prelim_tiebreak"][p.ID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(preRows, func(i, j int) bool {
|
||||||
|
if preRows[i].Score != preRows[j].Score {
|
||||||
|
return preRows[i].Score > preRows[j].Score
|
||||||
|
}
|
||||||
|
return preRows[i].PlayerID < preRows[j].PlayerID
|
||||||
|
})
|
||||||
|
assignDenseRankByScore(preRows)
|
||||||
|
|
||||||
|
preTie := TieBreakInfo{Required: false, Resolved: true, Slots: 0, PlayerIDs: []int{}}
|
||||||
|
finalists := []RankingRow{}
|
||||||
|
|
||||||
|
if len(preRows) <= 12 {
|
||||||
|
for i := range preRows {
|
||||||
|
row := preRows[i]
|
||||||
|
row.Seed = i + 1
|
||||||
|
finalists = append(finalists, row)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cutoff := preRows[11].Score
|
||||||
|
above := []RankingRow{}
|
||||||
|
atCutoff := []RankingRow{}
|
||||||
|
for _, row := range preRows {
|
||||||
|
if row.Score > cutoff {
|
||||||
|
above = append(above, row)
|
||||||
|
} else if row.Score == cutoff {
|
||||||
|
atCutoff = append(atCutoff, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slots := 12 - len(above)
|
||||||
|
if slots < 0 {
|
||||||
|
slots = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(atCutoff) <= slots {
|
||||||
|
finalists = append(finalists, above...)
|
||||||
|
finalists = append(finalists, atCutoff...)
|
||||||
|
} else {
|
||||||
|
preTie.Required = true
|
||||||
|
preTie.Slots = slots
|
||||||
|
preTie.Resolved = true
|
||||||
|
for _, row := range atCutoff {
|
||||||
|
preTie.PlayerIDs = append(preTie.PlayerIDs, row.PlayerID)
|
||||||
|
}
|
||||||
|
sort.Ints(preTie.PlayerIDs)
|
||||||
|
|
||||||
|
sort.SliceStable(atCutoff, func(i, j int) bool {
|
||||||
|
if atCutoff[i].TieBreak != atCutoff[j].TieBreak {
|
||||||
|
return atCutoff[i].TieBreak > atCutoff[j].TieBreak
|
||||||
|
}
|
||||||
|
return atCutoff[i].PlayerID < atCutoff[j].PlayerID
|
||||||
|
})
|
||||||
|
|
||||||
|
if slots > 0 {
|
||||||
|
boundary := atCutoff[slots-1].TieBreak
|
||||||
|
greater := 0
|
||||||
|
equal := 0
|
||||||
|
for _, row := range atCutoff {
|
||||||
|
if row.TieBreak > boundary {
|
||||||
|
greater++
|
||||||
|
} else if row.TieBreak == boundary {
|
||||||
|
equal++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if greater < slots && greater+equal > slots {
|
||||||
|
preTie.Resolved = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalists = append(finalists, above...)
|
||||||
|
if slots > len(atCutoff) {
|
||||||
|
slots = len(atCutoff)
|
||||||
|
}
|
||||||
|
finalists = append(finalists, atCutoff[:slots]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(finalists, func(i, j int) bool {
|
||||||
|
if finalists[i].Score != finalists[j].Score {
|
||||||
|
return finalists[i].Score > finalists[j].Score
|
||||||
|
}
|
||||||
|
if preTie.Required {
|
||||||
|
if finalists[i].TieBreak != finalists[j].TieBreak {
|
||||||
|
return finalists[i].TieBreak > finalists[j].TieBreak
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finalists[i].PlayerID < finalists[j].PlayerID
|
||||||
|
})
|
||||||
|
assignDenseRankBy(finalists, func(a, b RankingRow) bool {
|
||||||
|
if a.Score != b.Score {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if preTie.Required {
|
||||||
|
return a.TieBreak == b.TieBreak
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range finalists {
|
||||||
|
finalists[i].Seed = i + 1
|
||||||
|
if i < 6 {
|
||||||
|
finalists[i].FinalGroup = 1
|
||||||
|
} else {
|
||||||
|
finalists[i].FinalGroup = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalGroup1 := []RankingRow{}
|
||||||
|
finalGroup2 := []RankingRow{}
|
||||||
|
for _, row := range finalists {
|
||||||
|
if row.FinalGroup == 1 {
|
||||||
|
finalGroup1 = append(finalGroup1, row)
|
||||||
|
} else {
|
||||||
|
finalGroup2 = append(finalGroup2, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalRows := []RankingRow{}
|
||||||
|
for _, finalist := range finalists {
|
||||||
|
p := playerByID[finalist.PlayerID]
|
||||||
|
finalRows = append(finalRows, RankingRow{
|
||||||
|
PlayerID: finalist.PlayerID,
|
||||||
|
NameAr: p.NameAr,
|
||||||
|
NameEn: p.NameEn,
|
||||||
|
GroupCode: p.GroupCode,
|
||||||
|
ImageData: p.ImageData,
|
||||||
|
Score: scoreTotal(scores, finalist.PlayerID, finalRoundStages),
|
||||||
|
TieBreak: scores["final_tiebreak"][finalist.PlayerID],
|
||||||
|
Seed: finalist.Seed,
|
||||||
|
FinalGroup: finalist.FinalGroup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(finalRows, func(i, j int) bool {
|
||||||
|
if finalRows[i].Score != finalRows[j].Score {
|
||||||
|
return finalRows[i].Score > finalRows[j].Score
|
||||||
|
}
|
||||||
|
return finalRows[i].Seed < finalRows[j].Seed
|
||||||
|
})
|
||||||
|
|
||||||
|
finalTie := TieBreakInfo{Required: false, Resolved: true, Slots: 0, PlayerIDs: []int{}}
|
||||||
|
tiedTop := map[int]bool{}
|
||||||
|
if len(finalRows) > 0 {
|
||||||
|
i := 0
|
||||||
|
for i < len(finalRows) {
|
||||||
|
j := i + 1
|
||||||
|
for j < len(finalRows) && finalRows[j].Score == finalRows[i].Score {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j-i > 1 && finalRows[i].Score > 0 {
|
||||||
|
startPos := i + 1
|
||||||
|
endPos := j
|
||||||
|
if startPos <= 3 || endPos <= 3 || (startPos < 3 && endPos > 3) {
|
||||||
|
for k := i; k < j; k++ {
|
||||||
|
tiedTop[finalRows[k].PlayerID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tiedTop) > 0 {
|
||||||
|
finalTie.Required = true
|
||||||
|
for id := range tiedTop {
|
||||||
|
finalTie.PlayerIDs = append(finalTie.PlayerIDs, id)
|
||||||
|
}
|
||||||
|
sort.Ints(finalTie.PlayerIDs)
|
||||||
|
|
||||||
|
sort.SliceStable(finalRows, func(i, j int) bool {
|
||||||
|
if finalRows[i].Score != finalRows[j].Score {
|
||||||
|
return finalRows[i].Score > finalRows[j].Score
|
||||||
|
}
|
||||||
|
iti := tiedTop[finalRows[i].PlayerID]
|
||||||
|
itj := tiedTop[finalRows[j].PlayerID]
|
||||||
|
if iti && itj && finalRows[i].TieBreak != finalRows[j].TieBreak {
|
||||||
|
return finalRows[i].TieBreak > finalRows[j].TieBreak
|
||||||
|
}
|
||||||
|
return finalRows[i].Seed < finalRows[j].Seed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalTie.Required {
|
||||||
|
assignDenseRankBy(finalRows, func(a, b RankingRow) bool {
|
||||||
|
if a.Score != b.Score {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if tiedTop[a.PlayerID] && tiedTop[b.PlayerID] {
|
||||||
|
return a.TieBreak == b.TieBreak
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if len(finalRows) >= 3 {
|
||||||
|
third := finalRows[2]
|
||||||
|
greater := 0
|
||||||
|
equal := 0
|
||||||
|
for _, row := range finalRows {
|
||||||
|
if row.Score > third.Score {
|
||||||
|
greater++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if row.Score == third.Score {
|
||||||
|
itied := tiedTop[row.PlayerID]
|
||||||
|
ttied := tiedTop[third.PlayerID]
|
||||||
|
if itied && ttied {
|
||||||
|
if row.TieBreak > third.TieBreak {
|
||||||
|
greater++
|
||||||
|
} else if row.TieBreak == third.TieBreak {
|
||||||
|
equal++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
equal++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if greater < 3 && greater+equal > 3 {
|
||||||
|
finalTie.Resolved = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assignDenseRankByScore(finalRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
podium := []RankingRow{}
|
||||||
|
for i := 0; i < len(finalRows) && i < 3; i++ {
|
||||||
|
podium = append(podium, finalRows[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return DerivedState{
|
||||||
|
PreliminaryRanking: RankingBundle{Rows: preRows, TieBreak: preTie, Unresolved: preTie.Required && !preTie.Resolved},
|
||||||
|
Finalists: finalists,
|
||||||
|
FinalGroups: FinalGroups{Group1: finalGroup1, Group2: finalGroup2},
|
||||||
|
FinalRanking: RankingBundle{Rows: finalRows, TieBreak: finalTie, Unresolved: finalTie.Required && !finalTie.Resolved},
|
||||||
|
Podium: podium,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreTotal(scores map[string]map[int]int, playerID int, stages []string) int {
|
||||||
|
total := 0
|
||||||
|
for _, stage := range stages {
|
||||||
|
stageMap, ok := scores[stage]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += stageMap[playerID]
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignDenseRankByScore(rows []RankingRow) {
|
||||||
|
assignDenseRankBy(rows, func(a, b RankingRow) bool {
|
||||||
|
return a.Score == b.Score
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignDenseRankBy(rows []RankingRow, isEqual func(a, b RankingRow) bool) {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRank := 1
|
||||||
|
rows[0].Rank = currentRank
|
||||||
|
for i := 1; i < len(rows); i++ {
|
||||||
|
if !isEqual(rows[i], rows[i-1]) {
|
||||||
|
currentRank = i + 1
|
||||||
|
}
|
||||||
|
rows[i].Rank = currentRank
|
||||||
|
}
|
||||||
|
}
|
||||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
shooting-event:
|
||||||
|
container_name: shooting-event
|
||||||
|
image: ${IMAGE:-repo.ssp-itinfra.com/admin/shooting-event:amd64-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-8080}:8080"
|
||||||
|
environment:
|
||||||
|
PORT: "8080"
|
||||||
|
DB_PATH: /app/data/shooting.db
|
||||||
|
WEB_DIR: /app/web
|
||||||
|
ADMIN_USER: ${ADMIN_USER:-datwyler}
|
||||||
|
ADMIN_PASS: ${ADMIN_PASS:-datwyler}
|
||||||
|
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||||
|
GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.0-flash}
|
||||||
|
volumes:
|
||||||
|
- shooting_event_data:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/api/health >/dev/null 2>&1 || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
shooting_event_data:
|
||||||
|
name: shooting_event_data
|
||||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ar" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Shooting Event Tracker</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=Cairo:wght@400;600;700;800&family=IBM+Plex+Mono:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
frontend/package.json
Normal file
21
frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "shooting-event-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0 --port 4173"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.22"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"vite": "^5.4.19"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
747
frontend/pnpm-lock.yaml
generated
Normal file
747
frontend/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
vue:
|
||||||
|
specifier: ^3.5.22
|
||||||
|
version: 3.5.31
|
||||||
|
devDependencies:
|
||||||
|
'@vitejs/plugin-vue':
|
||||||
|
specifier: ^5.2.1
|
||||||
|
version: 5.2.4(vite@5.4.21)(vue@3.5.31)
|
||||||
|
vite:
|
||||||
|
specifier: ^5.4.19
|
||||||
|
version: 5.4.21
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.27.1':
|
||||||
|
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.28.5':
|
||||||
|
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/parser@7.29.2':
|
||||||
|
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/types@7.29.0':
|
||||||
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.21.5':
|
||||||
|
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.21.5':
|
||||||
|
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.21.5':
|
||||||
|
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.21.5':
|
||||||
|
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.21.5':
|
||||||
|
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.21.5':
|
||||||
|
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5':
|
||||||
|
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm-eabi@4.60.1':
|
||||||
|
resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-arm64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-x64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-arm64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-x64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf@4.60.1':
|
||||||
|
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
|
||||||
|
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-musl@4.60.1':
|
||||||
|
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-musl@4.60.1':
|
||||||
|
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-musl@4.60.1':
|
||||||
|
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-musl@4.60.1':
|
||||||
|
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-s390x-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-musl@4.60.1':
|
||||||
|
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-openbsd-x64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-openharmony-arm64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-arm64-msvc@4.60.1':
|
||||||
|
resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-ia32-msvc@4.60.1':
|
||||||
|
resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-msvc@4.60.1':
|
||||||
|
resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@types/estree@1.0.8':
|
||||||
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@vitejs/plugin-vue@5.2.4':
|
||||||
|
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
vite: ^5.0.0 || ^6.0.0
|
||||||
|
vue: ^3.2.25
|
||||||
|
|
||||||
|
'@vue/compiler-core@3.5.31':
|
||||||
|
resolution: {integrity: sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==}
|
||||||
|
|
||||||
|
'@vue/compiler-dom@3.5.31':
|
||||||
|
resolution: {integrity: sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==}
|
||||||
|
|
||||||
|
'@vue/compiler-sfc@3.5.31':
|
||||||
|
resolution: {integrity: sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==}
|
||||||
|
|
||||||
|
'@vue/compiler-ssr@3.5.31':
|
||||||
|
resolution: {integrity: sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==}
|
||||||
|
|
||||||
|
'@vue/reactivity@3.5.31':
|
||||||
|
resolution: {integrity: sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==}
|
||||||
|
|
||||||
|
'@vue/runtime-core@3.5.31':
|
||||||
|
resolution: {integrity: sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==}
|
||||||
|
|
||||||
|
'@vue/runtime-dom@3.5.31':
|
||||||
|
resolution: {integrity: sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==}
|
||||||
|
|
||||||
|
'@vue/server-renderer@3.5.31':
|
||||||
|
resolution: {integrity: sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: 3.5.31
|
||||||
|
|
||||||
|
'@vue/shared@3.5.31':
|
||||||
|
resolution: {integrity: sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==}
|
||||||
|
|
||||||
|
csstype@3.2.3:
|
||||||
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
entities@7.0.1:
|
||||||
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
esbuild@0.21.5:
|
||||||
|
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
estree-walker@2.0.2:
|
||||||
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
nanoid@3.3.11:
|
||||||
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
picocolors@1.1.1:
|
||||||
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
postcss@8.5.8:
|
||||||
|
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||||
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
rollup@4.60.1:
|
||||||
|
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
|
||||||
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
source-map-js@1.2.1:
|
||||||
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
vite@5.4.21:
|
||||||
|
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': ^18.0.0 || >=20.0.0
|
||||||
|
less: '*'
|
||||||
|
lightningcss: ^1.21.0
|
||||||
|
sass: '*'
|
||||||
|
sass-embedded: '*'
|
||||||
|
stylus: '*'
|
||||||
|
sugarss: '*'
|
||||||
|
terser: ^5.4.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
less:
|
||||||
|
optional: true
|
||||||
|
lightningcss:
|
||||||
|
optional: true
|
||||||
|
sass:
|
||||||
|
optional: true
|
||||||
|
sass-embedded:
|
||||||
|
optional: true
|
||||||
|
stylus:
|
||||||
|
optional: true
|
||||||
|
sugarss:
|
||||||
|
optional: true
|
||||||
|
terser:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
vue@3.5.31:
|
||||||
|
resolution: {integrity: sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.27.1': {}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.28.5': {}
|
||||||
|
|
||||||
|
'@babel/parser@7.29.2':
|
||||||
|
dependencies:
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@babel/types@7.29.0':
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-string-parser': 7.27.1
|
||||||
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm-eabi@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-arm64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-x64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-arm64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-x64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-musl@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-musl@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-musl@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-musl@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-s390x-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-musl@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-openbsd-x64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-openharmony-arm64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-arm64-msvc@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-ia32-msvc@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-msvc@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@vitejs/plugin-vue@5.2.4(vite@5.4.21)(vue@3.5.31)':
|
||||||
|
dependencies:
|
||||||
|
vite: 5.4.21
|
||||||
|
vue: 3.5.31
|
||||||
|
|
||||||
|
'@vue/compiler-core@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.2
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
entities: 7.0.1
|
||||||
|
estree-walker: 2.0.2
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
'@vue/compiler-dom@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-core': 3.5.31
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
|
||||||
|
'@vue/compiler-sfc@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.2
|
||||||
|
'@vue/compiler-core': 3.5.31
|
||||||
|
'@vue/compiler-dom': 3.5.31
|
||||||
|
'@vue/compiler-ssr': 3.5.31
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
estree-walker: 2.0.2
|
||||||
|
magic-string: 0.30.21
|
||||||
|
postcss: 8.5.8
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
'@vue/compiler-ssr@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-dom': 3.5.31
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
|
||||||
|
'@vue/reactivity@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
|
||||||
|
'@vue/runtime-core@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@vue/reactivity': 3.5.31
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
|
||||||
|
'@vue/runtime-dom@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@vue/reactivity': 3.5.31
|
||||||
|
'@vue/runtime-core': 3.5.31
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@vue/server-renderer@3.5.31(vue@3.5.31)':
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-ssr': 3.5.31
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
vue: 3.5.31
|
||||||
|
|
||||||
|
'@vue/shared@3.5.31': {}
|
||||||
|
|
||||||
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
entities@7.0.1: {}
|
||||||
|
|
||||||
|
esbuild@0.21.5:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/aix-ppc64': 0.21.5
|
||||||
|
'@esbuild/android-arm': 0.21.5
|
||||||
|
'@esbuild/android-arm64': 0.21.5
|
||||||
|
'@esbuild/android-x64': 0.21.5
|
||||||
|
'@esbuild/darwin-arm64': 0.21.5
|
||||||
|
'@esbuild/darwin-x64': 0.21.5
|
||||||
|
'@esbuild/freebsd-arm64': 0.21.5
|
||||||
|
'@esbuild/freebsd-x64': 0.21.5
|
||||||
|
'@esbuild/linux-arm': 0.21.5
|
||||||
|
'@esbuild/linux-arm64': 0.21.5
|
||||||
|
'@esbuild/linux-ia32': 0.21.5
|
||||||
|
'@esbuild/linux-loong64': 0.21.5
|
||||||
|
'@esbuild/linux-mips64el': 0.21.5
|
||||||
|
'@esbuild/linux-ppc64': 0.21.5
|
||||||
|
'@esbuild/linux-riscv64': 0.21.5
|
||||||
|
'@esbuild/linux-s390x': 0.21.5
|
||||||
|
'@esbuild/linux-x64': 0.21.5
|
||||||
|
'@esbuild/netbsd-x64': 0.21.5
|
||||||
|
'@esbuild/openbsd-x64': 0.21.5
|
||||||
|
'@esbuild/sunos-x64': 0.21.5
|
||||||
|
'@esbuild/win32-arm64': 0.21.5
|
||||||
|
'@esbuild/win32-ia32': 0.21.5
|
||||||
|
'@esbuild/win32-x64': 0.21.5
|
||||||
|
|
||||||
|
estree-walker@2.0.2: {}
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
postcss@8.5.8:
|
||||||
|
dependencies:
|
||||||
|
nanoid: 3.3.11
|
||||||
|
picocolors: 1.1.1
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
rollup@4.60.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.8
|
||||||
|
optionalDependencies:
|
||||||
|
'@rollup/rollup-android-arm-eabi': 4.60.1
|
||||||
|
'@rollup/rollup-android-arm64': 4.60.1
|
||||||
|
'@rollup/rollup-darwin-arm64': 4.60.1
|
||||||
|
'@rollup/rollup-darwin-x64': 4.60.1
|
||||||
|
'@rollup/rollup-freebsd-arm64': 4.60.1
|
||||||
|
'@rollup/rollup-freebsd-x64': 4.60.1
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf': 4.60.1
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf': 4.60.1
|
||||||
|
'@rollup/rollup-linux-arm64-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-linux-arm64-musl': 4.60.1
|
||||||
|
'@rollup/rollup-linux-loong64-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-linux-loong64-musl': 4.60.1
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-linux-ppc64-musl': 4.60.1
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-linux-riscv64-musl': 4.60.1
|
||||||
|
'@rollup/rollup-linux-s390x-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-linux-x64-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-linux-x64-musl': 4.60.1
|
||||||
|
'@rollup/rollup-openbsd-x64': 4.60.1
|
||||||
|
'@rollup/rollup-openharmony-arm64': 4.60.1
|
||||||
|
'@rollup/rollup-win32-arm64-msvc': 4.60.1
|
||||||
|
'@rollup/rollup-win32-ia32-msvc': 4.60.1
|
||||||
|
'@rollup/rollup-win32-x64-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-win32-x64-msvc': 4.60.1
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
vite@5.4.21:
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.21.5
|
||||||
|
postcss: 8.5.8
|
||||||
|
rollup: 4.60.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
vue@3.5.31:
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-dom': 3.5.31
|
||||||
|
'@vue/compiler-sfc': 3.5.31
|
||||||
|
'@vue/runtime-dom': 3.5.31
|
||||||
|
'@vue/server-renderer': 3.5.31(vue@3.5.31)
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
BIN
frontend/public/bg.png
Normal file
BIN
frontend/public/bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
BIN
frontend/public/bg_live.png
Normal file
BIN
frontend/public/bg_live.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 229 KiB |
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
63
frontend/public/logo.svg
Normal file
63
frontend/public/logo.svg
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 583.65 114.56">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #008ca8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #ec2f23;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #1b1f40;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g>
|
||||||
|
<path class="cls-3" d="M545.23,98.25l4.31-7.15c.28-.46.13-1.06-.33-1.33-2.22-1.32-8.46-4.34-10.8-5.19-.64-.23-.88-.38-1.41.22-.65.73-4.89,7.9-4.78,8.41.1.46,8,4.3,11.13,5.65.69.3,1.5.04,1.9-.61Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-3" d="M392.94,85.7c5.81.93,14.01.79,19.19-2.28,3.16-1.87,4.61-5.35,5.87-8.63l1.32,4.87c3.19,8.82,8.21,15.31,18.58,12.74,10.58-2.63,13.61-18.14,12.73-27.42-.65-6.87-2.57-13.59-3.36-20.44l1.61-.11,3.36-9.01c.02-.45-.27-.62-.56-.87-2.28-1.94-6.47-3.24-8.09-6.06-1.38-2.41-.88-4.59-.39-7.13-.32-.06-.53.03-.81.16-1.84.87-5.37,7.2-5.99,9.22-.44,1.44-.52,2.45,0,3.88.32.91,1.51,2.23,1.64,2.78.19.82-.16,1.4-.15,2.08.29,10.93,4.43,22.34,6.47,33,.24,1.27,1.13,5.76.62,6.65-.87,1.53-4.98,2.6-6.65,2.73-13.69,1.08-17.23-22.88-17.26-24.5-.02-1.63,3.27-6.22,4.17-8.05.29-.6,1.45-2.94,1.31-3.43l-.52-.21-5.3,7.35c-.93-3.2-1.96-6.43-2.15-9.79-.69-.95-1.17.03-1.63.61-2.69,3.35-3.93,5.91-3.64,10.33.04.65.56,1.64.19,2.14-7.63,4.11-16.83,6.5-21.56,14.35-1.4,2.33-4.47,9.4-4.06,11.96.39,2.45,3.05,2.76,5.05,3.08ZM398.75,71.81c5.39-3.26,11.6-5.51,16.24-9.94.49,2.58,1.29,5.14,1.62,7.75.07.57.35,1.65,0,2.1-.33.43-3.52,2.14-4.2,2.44-7.89,3.39-16.94.64-17.15.36-.11-.47,2.94-2.38,3.48-2.71Z"/>
|
||||||
|
<path class="cls-3" d="M384.64,43.49l10.08,4.98c.43.21.94.06,1.19-.35l3.86-6.41c3.08.93,5.9,2.66,8.65,4.33.21.12.41.05.58-.09l5.38-7.11c.28-.37.19-.89-.2-1.14-1.87-1.21-7.19-4.06-9.28-4.89-1.71-.67-1.63-.51-2.65.83-1.28,1.68-2.21,3.59-3.39,5.34-.64.19-6.86-3.71-9.28-4.05-.37-.05-.74.13-.94.45l-4.34,6.88c-.27.43-.11,1,.35,1.23Z"/>
|
||||||
|
<path class="cls-1" d="M412.38,30.98c5.42-3.61,12.17-5.45,18.12-8.01,1.55-.67,4.04-1.64,5.19-2.83.46-.48,2.17-3.45,2.03-3.98l-.35-.22c-1.06.57-2.11,1.19-3.21,1.69-5.69,2.56-12.2,4.4-17.73,7.18-3.1,1.56-5.78,3.95-6.06,7.62.12.14,1.76-1.28,2-1.44Z"/>
|
||||||
|
<path class="cls-1" d="M544.19,36.97c.53-.43,2.67-2.92,2.87-2.97,1.09-.27,2.33,2.56,3.41.76,1.27-2.13.29-4.12-1.85-5.02,3.63-5.13-1.82-8.77-5.51-4-2.26,2.93-2.19,6,1.66,7.24.3.4-5.23,3.9-5.71,4.19-2.66,1.58-5.51,2.96-8.26,4.39-.68.35-2.27.92-2.73,1.27-.28.21-.32.27-.27.64,8.41-1.44,14.63-5.05,16.39-6.5ZM544.77,28.23c-.28-1.72,3.07-1.19,2.34.92-.62.13-2.23-.22-2.34-.92Z"/>
|
||||||
|
<path class="cls-1" d="M487.55,49.01c.45-.37,2.28-2.5,2.46-2.54.93-.23,2,2.19,2.92.65,1.09-1.82.25-3.53-1.58-4.3,3.11-4.39-1.56-7.51-4.72-3.42-1.93,2.51-1.88,5.14,1.42,6.2.26.35-4.47,3.34-4.89,3.58-2.28,1.35-4.72,2.53-7.07,3.75-.58.3-1.94.78-2.33,1.08-.24.18-.28.23-.23.55,7.19-1.23,12.52-4.32,14.03-5.56ZM488.04,41.53c-.24-1.47,2.63-1.02,2,.79-.53.12-1.91-.19-2-.79Z"/>
|
||||||
|
<path class="cls-2" d="M475.87,43.99c.16-.42,1.45-4.23.57-4.22l-2.7,2.54c-.95.9-2.32,1.27-3.56.88-2.75-.86-3.43-4.62-3.2-7.68.37-4.86,3.22-9.04,4.99-13.43-.05-.06-.59.33-.71.44-4.34,4.2-8.86,15.7-7.73,21.64,1.59,8.37,9.66,6.77,12.34-.16Z"/>
|
||||||
|
<path class="cls-3" d="M560.15,59.59c-.48-6.52-4.32-9.98-8.51-15.45-.96-.25-1.27.94-1.6,1.63-.78,1.57-2.91,6.79-2.81,8.35.16,2.1,4.24,6.97,5.41,9.28.31.61,1.54,3.25,1.24,3.73-.45.71-7.04,2.47-8.23,2.74-4.77,1.09-9.7,1.58-14.5,2.56-.26-.23,6.7-5.19,9.46-13.62,1.79-5.5-6.64-10.43-11.05-11.41-7.4-1.67-15.05,3.33-20.35,7.96l-4.23,4.17c.47-1.51,1.23-2.9,1.67-4.43,2.64-9.01-.34-20.1-.44-20.34.63-.09,1.27.71,1.67-.01.87-2.38,1.4-4.98,2.45-7.3.22-.5,1.13-1.55,1-1.95-5.81-3.68-12.82-6.43-10.61-13.11-.27-1.24-2.06,1.48-2.25,1.79-1.2,1.99-3.4,6.92-3.4,9.17,0,3.07,2.12,4.11,2.46,5.87.12.63-.18,1.95-.16,2.8.14,4.15,2.12,9.94,2.96,14.21,1.93,9.78,2.55,16.11-4.81,23.74-.72.75-9.19,3.71-10.2,2.78-2.26-6.39-10.51-19.18-18.41-12.73-3.52,2.87-6.25,8.73-7.48,13.04-2.5,8.83,4.32,10.97,11.65,11.28,2.5.12,4.52-.38,6.9-.52,1.11-.08,1.99-.09,1.23,1.22-.92,1.58-15.56,16.28-29.89,15.6-2.72-.13-10.67-1.83-14.21-2.81-.34-.09-.56.36-.27.57.75.54,10.15,7.16,16.92,8.41,1.27.23,3.27.21,4.65.21,0,0-1.44.38-1.44.7,0,.1.12.65,1.23.89.83.18,2.45.26,2.45.26,9.13-.07,15.98-8.55,19.69-13.13,2.55-3.14,4.57-6.66,5.78-10.36.14-.41.17-1.55.6-1.7.53-.18,26.49-2.3,34.5-1.32,9.25,1.15,24.69-1.36,32.76-6.16,4.68-2.78,8.59-11.22,8.2-16.59ZM465.63,71.52c-.88-1.54,1.33-3.08,2.77-3.09,1.67-.01,6.42,4.66,6.26,5.29-1.81-.05-8.13-.62-9.03-2.2ZM500.54,70.49c-.08-.31.13-.36.26-.53.7-.92,1.9-1.73,2.74-2.56,4.37-4.31,11.57-10.71,18.14-10.36,3.91.21,7.97,2.72,10.69,5.41.06.28-.23.36-.4.47-.84.56-2.8,1.31-3.79,1.7-8.75,3.39-18.36,4.8-27.64,5.89Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="cls-2" d="M217.93,56.12c2.78-1.56,3.83-5.94,4.41-8.84l-.31-.22c-1.28,2.08-4,4.11-6.31,2.14-2.91-2.49-1.56-7.07-.57-10.19.99-3.12,2.59-5.94,3.94-8.89,0,0-12.84,14.54-7.4,24.01,1.32,2.29,3.82,3.34,6.23,1.98Z"/>
|
||||||
|
<path class="cls-1" d="M339.55,40.67c-.21.38-1.07,2.03-1.03,2.32.02.13.31.35.47.12,5.9-4.13,13.01-5.94,19.63-8.59,4.02-1.61,7.78-2.67,10.3-6.45.73-1.09,1.59-2.51,1.51-3.86l-2.7,1.79c-8.01,5.05-23.37,5.98-28.18,14.67Z"/>
|
||||||
|
<path class="cls-2" d="M251.93,48.45c2.1,1.09,3.59-.9,4.9-2.21,2.22,1.02,4.11.54,5.3-1.62,1.28-2.33.53-4.16-.14-6.51-.85-.84-1.13,1.52-1.14,2.06,0,1.09.59,2.4-.66,3.18-1.34.83-2.38-.6-2.52-1.85-.07-.63,0-1.46,0-2.12,0-.74-.76.64-.82.77-.47,1.06-.45,1.75-.67,2.78-.59,2.71-3.83,3.62-4,.25-.02-.42.22-1.76.17-1.92-.3-1.14-1.3,1.41-1.38,1.65-.51,1.6-.84,4.62.95,5.55Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-3" d="M371.43,67.19c-1.92-5.4-10.44-12.79-16.07-15.06-2.28-.92-4.29-.92-5.95,0-2.69,1.49-3.83,5.03-4.31,7.74-.25,1.55-.59,4.2,0,4.62l.2.14.24-.06c.68-.17,1.25-.53,1.8-.88.74-.46,1.37-.86,2.21-.86,2.34,0,8.55,5.01,11.36,7.7,1.77,1.69,3.56,3.77,5.08,5.88-.43.22-1.49.65-4.1,1.45l-.11.03c-3.7,1.13-7.91,2.17-11.53,2.85-.22.04-.46.09-.72.14-2.51.5-6.71,1.33-7.93-.74-.15-.26-.25-.58-.35-.91-.16-.55-.35-1.17-.87-1.63l-.15-.14h-.2c-.48,0-.73.14-1.62,3.9-1.22,5.13-.98,8.35.76,10.12,1.07,1.08,2.69,1.61,4.97,1.61,1.81,0,4.05-.31,7.26-1.01,3.87-.85,15.21-3.89,17.47-6.22.98-1.01,1.5-2.63,1.67-3.28,1.25-4.65,2.41-11.09.88-15.39Z"/>
|
||||||
|
<path class="cls-3" d="M334.01,41.61l-.26.06.26-.07c-1.94-8.37-3.96-17.03-3.69-24.32,0-.1.05-.23.1-.36.13-.34.33-.85-.03-1.44l-.12-.2-.23-.05c-.98-.2-1.59.44-2.05.91l-.1.11c-6.98,7.12-6.41,14.05-4.94,22.37.83,4.72,2,9.24,3.13,13.64,2.89,11.24,5.63,21.85,2.6,34.3-.08.31-.46,1.01-.77,1.57-.48.87-.75,1.38-.79,1.73-.05.49.17,1.06.68,1.18.04,0,.08.02.14.02.29,0,.61-.16,2.15-2.17,5.72-7.49,8.5-19.01,7.26-30.08-.61-5.44-2-11.42-3.34-17.2Z"/>
|
||||||
|
<path class="cls-3" d="M272.08,56.27l.25-.08-.25.08c2.16,6.63,4.2,12.89,3.73,20.06-.06.93-.34,2.15-.62,3.35-.33,1.43-.65,2.78-.63,3.76,0,.32.02.69.5,1.03l.39.27.31-.36c2.71-3.13,4.14-7.58,5.09-11.1,2.62-9.78,1.49-16.82-.91-26.14,0,0-.82-3.09-1.35-4.88-1.28-4.27-3.42-8.98-3.15-12.78.01-.17.09-.5.17-.84.25-1.09.54-2.32-.02-2.95-.13-.15-.43-.4-.96-.35-1.13.11-4.09,4.59-4.11,4.63-3.24,5.18-2.75,8.94-1.68,14.66.76,4.03,2.02,7.9,3.24,11.65Z"/>
|
||||||
|
<path class="cls-3" d="M287.73,37.28l10.4,5.14c.2.1.42.15.65.15.52,0,1-.27,1.27-.71l3.77-6.26c2.94.96,5.68,2.63,8.33,4.24l.09.06c.4.23.85.16,1.28-.24l5.55-7.34c.23-.3.32-.68.25-1.05-.07-.37-.28-.7-.6-.9-2.01-1.3-7.55-4.26-9.67-5.09-1.85-.73-2.16-.56-3.11.71l-.24.32c-.79,1.04-1.45,2.16-2.12,3.28-.39.65-.79,1.33-1.21,1.97-.54-.19-1.77-.83-2.8-1.36-2.2-1.13-4.93-2.54-6.44-2.76-.6-.09-1.19.19-1.51.71l-4.48,7.1c-.21.34-.27.75-.17,1.14.11.39.38.71.74.89Z"/>
|
||||||
|
<path class="cls-3" d="M269.89,96.65l.1.06c.41.23.85.15,1.28-.24l5.55-7.34c.23-.3.32-.68.25-1.05s-.28-.7-.6-.9c-2.01-1.3-7.55-4.26-9.67-5.09-1.85-.73-2.16-.56-3.11.71l-.24.32c-.78,1.03-1.45,2.15-2.1,3.25-.39.66-.8,1.35-1.23,2-.54-.19-1.77-.83-2.8-1.36-2.2-1.13-4.93-2.54-6.44-2.76-.6-.09-1.19.19-1.51.71l-4.48,7.1c-.21.34-.27.75-.17,1.14.11.39.38.71.74.89l10.4,5.14c.2.1.42.15.65.15.52,0,1-.27,1.27-.71l3.77-6.26c2.94.96,5.68,2.63,8.33,4.24Z"/>
|
||||||
|
<path class="cls-2" d="M321.83,98.85h0c-.22.09-.44.3-.81.67-.29.28-.69.67-.94.79-1.38.68-2.96.36-3.76-.76-1.41-1.97-.24-5.48.76-7.94.22-.53.57-1.17.92-1.81.43-.78.87-1.59,1.08-2.25.13-.4.23-.71.03-1.27l-.11-.29-.3-.05c-.64-.12-.95.33-1.11.56-.02.03-.04.06-.07.09-2.28,2.79-6.98,10.67-6.09,15.95.29,1.71,1.13,3.01,2.5,3.85.79.48,1.61.73,2.46.73.73,0,1.47-.19,2.2-.55,2.26-1.13,4.14-3.97,4.37-6.6.02-.2-.02-.78-.4-1.05-.21-.15-.48-.18-.73-.07Z"/>
|
||||||
|
<path class="cls-2" d="M311.94,13.76h0c-.15.07-.31.21-.58.47-.21.2-.49.47-.67.56-.97.48-2.09.26-2.66-.54-1-1.4-.17-3.88.54-5.62.15-.38.4-.83.65-1.28.3-.55.62-1.12.77-1.59.09-.28.16-.5.02-.9l-.07-.2-.21-.04c-.45-.08-.67.23-.79.4-.02.02-.03.05-.05.07-1.61,1.97-4.94,7.55-4.31,11.29.2,1.21.8,2.13,1.77,2.72.56.34,1.14.52,1.74.52.52,0,1.04-.13,1.56-.39,1.6-.8,2.93-2.81,3.09-4.67.01-.14-.01-.55-.28-.75-.15-.11-.34-.13-.51-.05Z"/>
|
||||||
|
<path class="cls-3" d="M324.39,61.48c-.46-4.68-6.51-11.83-8.36-13.91-.37-.4-.7-.72-1.03-.98l-.06-.04h-.07c-.37-.07-.58.22-.71.39l-.04.05c-1.1,1.4-3.82,6.09-4.01,7.99-.23,2.25,1.86,4.95,3.53,7.13.52.68,1.02,1.32,1.39,1.88.08.13.19.29.32.47.63.92,2.09,3.06,2.07,3.74,0,.12-.22.8-3.83,2.4-1.45.63-3.84,1.5-4.41,1.5-1.17-4.42-6.15-11.55-11.48-12.92-2.47-.64-4.66.05-6.51,2.05-2.31,2.49-4.2,6.39-5.2,9.22-1.55,4.38-2.29,7.83-.87,10.45.99,1.81,2.94,3.04,5.98,3.76,3.75.89,8.59.71,12.73-.47-.07.19-.18.42-.28.62-1.7,3.37-15.28,16.69-29.4,16.03-2.54-.12-9.74-2.12-13.6-3.2l-.94-.26c-.28-.08-.56.06-.69.32-.13.26-.05.57.18.74.97.7,1.98,1.4,3.02,2.08,3.88,2.52,9.54,5.71,14.49,6.61.93.17,2.79.1,3.98.1-.07.04-.71.2-.76.25-.1.1-.15.21-.14.33,0,.09.06.86,1.47,1.17.82.18,1.64.27,2.45.27,3.53,0,7.57-1.23,10.55-3.2,4.24-2.81,13.94-12.5,15.28-23.12,5.5-2.28,9.66-4.87,12.1-10,.77-1.6,3.23-7.14,2.82-11.46h0ZM298.91,73.94c-.72.07-2.88-.02-3.4-.08-1.46-.18-4.19-.96-4.43-1.88-.11-.4.28-1.4.66-1.7.5-.39,1.37-.39,2.47-.02,1.99.68,4.16,2.4,4.7,3.69Z"/>
|
||||||
|
<path class="cls-3" d="M267.79,59.46c-1.01-2.05-2.43-4.14-4.23-6.49-.09-.12-.19-.28-.28-.45-.33-.56-.79-1.32-1.46-1.29-.44.01-.8.33-1.1.96-.8,1.61-1.9,4.71-2.49,6.41l-.16.43c-.44,1.25-.95,2.67-.37,4.02.49,1.14,1.32,2.37,2.14,3.57.88,1.28,2.46,4.25,2.46,4.38,0,.01-.07.16-.57.35-.66.24-9.67,2.56-11.43-4.79-1.35-5.65-6.8-34.72-9.24-47.44-.47-2.44-1.6-4.55-2.34-3.71-.8.9-5.73,6.31-6.12,7.53-.09.31-.24.53-.15.89,0,.04.01.08.03.12,3.66,10.92,5.66,23.65,7.3,35.05,0,.01.01.03.01.04.31,2.12.64,4.16,1.04,6.11-.01.45-.2.9-.52,1.37-.55.73-1.52,1.46-2.73,2.05-1.05.51-2.21.86-3.09.96-.88.09-1.78.07-2.71-.08-2.87-.44-5.53-2.01-7.09-4.18-.17-.24-.35-.59-.53-.93-.25-.49-.52-1.01-.83-1.33l-.07-.07c-.51-.59-.94-.48-1.2-.32-.44.23-.72.92-1.05,1.81l-.08.24c-.8,2.04-2.18,6.03-2.33,8.09-.05.71.01,1.41.07,2.09l.04.35.16.15c1.84,1.56,3.33,2.93,4.6,4.51l.2.23c.88,1.08,1.06,1.34,1.09,1.38.2.71-.12,1.8-.92,3.18-.86,1.49-2.17,3.01-3.02,4l-.43.49c-13.12,15.57-24.05,12.43-28.72,11.08-.47-.13-.88-.24-1.22-.33l-.76-.17.12.77c.59,3.86,8.51,7.34,8.86,7.49l.17.05c.81.13,1.64.21,2.43.27-.11.17-.15.39-.13.61l.03.35.32.12c.44.17.9.31,1.38.4.68.13,1.38.2,2.16.2,1.13,0,2.38-.15,3.85-.45,11.24-2.32,18.68-13.46,21.06-23.48.07-.29.15-.77.27-1.46.12-.75.44-2.71.63-2.97.16-.2,1.13-.41,1.6-.52.36-.08.69-.15.95-.23,4.9-1.66,7.94-4.18,9.58-8.01,4.06,10.53,12.35,10.27,13.62,10.3,4.15.08,7.65-2.12,9.62-6.01.48-.96.9-1.86,1.28-2.74,2.61-6.15,2.5-10.42.32-14.91Z"/>
|
||||||
|
</g>
|
||||||
|
<path class="cls-1" d="M263.48,30.62c.13-.89-.04-.9-.76-.62-2.19.82-4.7,1.96-6.84,2.95-2.94,1.36-5.86,2.56-6.26,6.24.16.17,2.1-1.17,2.42-1.34,2.38-1.27,4.98-2.14,7.41-3.3,1.63-.78,3.73-1.94,4.02-3.92Z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="cls-3" d="M49.47,41.16c-.18.16-.38.22-.61.1-2.87-1.75-5.83-3.55-9.05-4.53l-4.04,6.71c-.26.43-.8.58-1.24.36l-10.55-5.21c-.48-.24-.65-.83-.36-1.28l4.54-7.2c.21-.34.61-.53,1-.48,2.53.36,9.03,4.43,9.7,4.23,1.23-1.82,2.21-3.83,3.55-5.59,1.07-1.41.98-1.58,2.77-.87,2.19.86,7.75,3.85,9.71,5.12.41.26.5.81.21,1.19l-5.63,7.44Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M115.09,48.87c8.37-4.02,18.12-7.1,26.81-10.56,5.22-2.08,9.22-2.94,11.61-8.6.15-.36.88-2.04.68-2.27l-3.54,1.92c-10.25,4.41-21.04,7.42-31.32,11.77-3.89,1.65-9.13,3.8-11.8,7.15-1.55,1.94-2.79,4.69-3.22,7.14l.36.21c3.1-2.61,6.76-4.99,10.42-6.75Z"/>
|
||||||
|
<path class="cls-1" d="M191.25,86.23c-8.15,3.26-17.96,5.21-25.73,8.78-3.2,1.47-5.5,3.98-6.5,7.36.26.05.44-.06.66-.14,2.34-.85,4.92-2.43,7.33-3.39,8.27-3.3,17.66-5.33,25.68-9.19,3.11-1.5,6.29-4.34,7.44-7.66-1.15.52-2.2,1.2-3.33,1.76-1.8.88-3.7,1.74-5.56,2.49Z"/>
|
||||||
|
<path class="cls-2" d="M141.79,56.7c1.9.04,2.21-1.19,3.19-2.26.16-.17,1.2-1.24,1.29-1.26.78-.19,1.62.6,2.3.71,3.33.53,6.08-2.7,6.16-5.81.02-.66-.97-5.78-1.26-5.98-.57-.39-1.14,1.15-1.21,1.56-.25,1.51.44,4.42-.59,5.49-.99,1.02-2.4.7-3.25-.3-.96-1.14-.75-2.62-.85-3.98-.06-.9-.49-.32-.75.15-1.22,2.15-.69,6.93-3.99,7.11-3.16.17-2.45-3.89-2.23-5.91-.54-.54-1.47,1.85-1.61,2.23-1,2.84-1.5,8.16,2.8,8.25Z"/>
|
||||||
|
<path class="cls-1" d="M69.89,49.89c3.39-1.55,9.76-3.16,11.37-6.74.2-.44.72-1.93-.17-1.8-3.48,1.66-7.18,2.86-10.66,4.51-3.02,1.43-6.09,3.25-6.67,6.86l.3.18c1.98-.92,3.83-2.1,5.82-3.01Z"/>
|
||||||
|
<path class="cls-2" d="M69.76,42.61c-.02.4.24.41.49.29.49-.23,1.84-4.67,2-5.45,1.41-6.83-1.25-13.04-6.48-17.42-.51-.42-1.85-1.63-2.47-1.35-.85.39-2.28,3.33-2.13,4.27.22,1.36,2.26,2.29,3.2,3.09,4.58,3.93,6.88,7.95,5.96,14.19-.11.77-.53,1.64-.57,2.38Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-3" d="M194.12,38.09l3.92-8.92c.17-.48.16-.87-.03-1.19-.1-.17-.35-.31-2.33-1.16-.64-.28-1.25-.54-1.47-.66-4.53-2.45-6.82-5.24-6.81-8.29,0-.25.11-.67.22-1.08.23-.85.42-1.59-.01-1.89-.38-.27-.93.05-1.42.39-2.16,1.53-5.87,8.92-5.5,11.73.12.91.63,1.72,1.08,2.44.32.5.61.98.68,1.35.13.69.12,1.71.12,2.7,0,.85-.01,1.73.07,2.45.14,1.27.41,2.65.66,3.99.1.52.2,1.03.29,1.53.73,4.03,1.66,8.09,2.57,12.02,1.23,5.34,2.51,10.86,3.27,16.38-.26.38-2.31,1.21-2.93,1.32-6.58,1.18-9.6-5.39-10.98-11.11-2.08-8.64-3.48-17.89-4.84-26.83-.59-3.87-1.19-7.87-1.84-11.64l.17-3.44-.11-.1c-.15-.15-.34-.22-.57-.2-.25.02-1.01.09-3.12,3.05-4.42,6.19-4.12,8.32-3.1,15.38.54,3.77,1.36,7.69,2.15,11.48,1.08,5.21,2.2,10.6,2.65,15.71.15,1.75-.16,2.65-1.33,3.85-2.46,2.5-5.33,2.72-7.31,2.47-2.54-.33-5.15-1.65-7.16-3.62-.22-.21-.53-.63-.83-1.03-.44-.58-.85-1.13-1.16-1.35-.22-.15-.58-.4-1.03-.08l-.07.05-4.3,9.93-.03.12c-.03,1.1,1.7,2.68,3.96,4.63.74.63,1.37,1.18,1.7,1.55.19.76-1.79,3.84-2.93,5.24-5.59,6.92-16.72,13.68-26.31,13.23-2.8-.13-10.92-2.12-14.56-3.13-.35-.1-.58.36-.28.58,3.09,2.23,10.85,7.46,17.39,8.65,1.31.24,3.53.26,4.93.26l-.84.21-.69.17-.07.14c-.06.14-.07.29,0,.42.13.27.49.46,1.22.65.74.19,1.55.27,2.41.27,3.53,0,7.81-1.44,10.4-3.16,7.58-5.01,12.84-13.13,14.45-22.3,2.38-.09,6.55-.59,9.72-2.85,2.1-1.49,3.93-4.35,5.02-7.77l.98,3.14c2.23,5.21,6.28,8.5,10.83,8.8,4.27.28,8.26-2.2,10.97-6.78,3.65-6.18,4.95-12.61,3.98-19.64-.41-2.99-1.09-6.01-1.75-8.93-.65-2.88-1.32-5.84-1.73-8.78.4.16,1.08.36,1.63-.32Z"/>
|
||||||
|
<path class="cls-3" d="M72.15,88.67c1.62,0,3.24-.28,4.8-.83,3.76-1.34,6.73-4.15,8.38-7.93,4.34-9.96,2.27-14.86-4.37-22.74-.14-.17-.29-.4-.46-.65-.51-.78-1.14-1.75-2-1.61l-.11.04c-.51.27-2.95,5.9-2.97,5.96-.44,1.23-.88,2.66-.76,3.84.14,1.34,1.85,3.88,3.51,6.33.86,1.27,1.67,2.47,2.08,3.26l.1.18c.51.96.66,1.38.68,1.54-3.41,1.96-7.57,2.02-11.45.18-6.66-3.17-8.85-12.14-10.61-19.35-.39-1.61-.77-3.14-1.17-4.54.06-.25.62-.96.93-1.34.25-.31.46-.58.59-.78.65-1.05,1.34-2.33,1.68-3,.09-.17.19-.35.28-.53.49-.9,1.04-1.92,1.15-2.93l.1-.94-5.6,5.6-2.05-9.19-.11-.07c-.09-.06-.25-.13-.46-.09-.64.12-1.39,1.18-1.4,1.19-1.27,1.86-3.66,7.82-3.68,10.17,0,.46.09.91.19,1.36.07.35.15.68.17,1l-.41.15c-1.44.53-2.93,1.08-4.33,1.72l-.31.14c-5.44,2.47-12.21,5.54-15.63,10.65-1.37,2.04-3.89,7.89-4.26,10.23-.48,3.03.9,3.67,3.24,4.36,5.32,1.58,16.59.97,21.27-1.83,2.78-1.67,3.64-4.11,4.55-6.7.22-.62.44-1.26.7-1.89,1.09,5.27,3.89,13.81,10.81,17.37,2.21,1.14,4.57,1.71,6.92,1.71ZM33.63,68.58c.09-.06.17-.11.25-.17,2.4-1.68,5.2-3.12,7.92-4.51,2.74-1.4,5.57-2.86,8.04-4.57.31-.22.61-.5.91-.77.23-.22.45-.42.65-.56l2.11,8.77c-.48.47-3.29,1.78-4.2,2.11-4.94,1.79-10.34,1.96-16.51.51.09-.29.42-.52.85-.8Z"/>
|
||||||
|
<path class="cls-3" d="M106.79,87.85c3.39-.34,7.1-4.21,8.72-8.67,3.47,1.19,7.15,2.4,10.89,3.22l.33.07c2.85.63,5.89,1.23,6.85.8.48-.21.71-.84.9-1.35.04-.11.08-.21.12-.3.51-1.18,3.08-7.19,3.42-9.56.56-3.84-2.05-9.51-5.8-12.65-2.5-2.09-5.21-2.79-7.64-1.98-4.72,1.58-6.8,6.35-8.81,10.97-1.23,2.82-2.39,5.49-4.08,7.19-1.4,1.42-2.91,2.03-4.5,1.81-2.72-.37-6.25-2.79-7.12-5.45-3.05-9.37-4.66-28.07-7.84-47.56-.39-2.41-1.53-4.35-2.31-3.66-.89.79-5.65,6.21-6.03,7.42-.11.34-.27.57-.13.99,8.04,24.03,1.75,60.83,23.02,58.68ZM120.95,68.88c.71-1.13,1.64-1.57,2.85-1.36,2.43.43,5.04,3.37,5.7,4.44-1.33-.22-4.76-1.4-6.16-1.96l-.17-.07c-1.57-.61-2.06-.92-2.21-1.06Z"/>
|
||||||
|
<path class="cls-3" d="M96.14,94.72c-2.04-1.31-7.63-4.3-9.77-5.15-1.77-.7-1.99-.58-2.91.65l-.25.33c-.79,1.04-1.46,2.17-2.11,3.27-.43.72-.87,1.47-1.34,2.18-.47-.12-1.81-.82-3.02-1.44-2.21-1.14-4.97-2.57-6.47-2.78-.52-.07-1.04.17-1.33.63l-4.54,7.2c-.19.29-.24.65-.14.99.09.33.33.61.64.77l10.55,5.21c.18.09.37.13.57.13.44,0,.87-.22,1.11-.62l3.91-6.49c3.06.97,5.9,2.7,8.65,4.37l.09.05c.19.11.57.23.99-.14l5.67-7.49c.19-.26.27-.59.22-.9-.06-.32-.24-.6-.51-.77Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 17 KiB |
1543
frontend/src/App.vue
Normal file
1543
frontend/src/App.vue
Normal file
File diff suppressed because it is too large
Load Diff
35
frontend/src/components/AdminLoginPanel.vue
Normal file
35
frontend/src/components/AdminLoginPanel.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<section class="panel admin-login-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('adminLogin') }}</h2>
|
||||||
|
<p>{{ t('adminLoginDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-login-grid">
|
||||||
|
<input :value="username" class="name-input" :placeholder="t('auth.username')" @input="$emit('update:username', $event.target.value)" />
|
||||||
|
<input
|
||||||
|
:value="password"
|
||||||
|
type="password"
|
||||||
|
class="name-input"
|
||||||
|
:placeholder="t('auth.password')"
|
||||||
|
@input="$emit('update:password', $event.target.value)"
|
||||||
|
@keyup.enter="$emit('submit')"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary" @click="$emit('submit')">{{ t('actions.login') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="auth-error" v-if="error">{{ error }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
username: { type: String, default: '' },
|
||||||
|
password: { type: String, default: '' },
|
||||||
|
error: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['submit', 'update:username', 'update:password'])
|
||||||
|
</script>
|
||||||
|
|
||||||
350
frontend/src/components/AdminPanel.vue
Normal file
350
frontend/src/components/AdminPanel.vue
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section class="panel admin-head-panel">
|
||||||
|
<div class="admin-head">
|
||||||
|
<div>
|
||||||
|
<h2>{{ t('adminPanel') }}</h2>
|
||||||
|
<p class="muted">{{ t('adminPanelDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn btn-secondary" @click="$emit('refresh')">{{ t('actions.refresh') }}</button>
|
||||||
|
<button class="btn btn-danger" @click="$emit('logout')">{{ t('actions.logout') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-settings-row">
|
||||||
|
<label class="switch-row">
|
||||||
|
<input type="checkbox" :checked="viewProofInView" @change="$emit('toggle-view-proof', $event.target.checked)" />
|
||||||
|
<span>{{ t('labels.viewProofInView') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="tab-bar admin-tab-bar">
|
||||||
|
<button v-for="tab in adminTabs" :key="tab.id" class="tab-btn" :class="{ active: adminTab === tab.id }" @click="$emit('change-admin-tab', tab.id)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<PlayersManagementTab
|
||||||
|
v-show="adminTab === 'players'"
|
||||||
|
:t="t"
|
||||||
|
:group-setup-input="groupSetupInput"
|
||||||
|
:admin-group-cards="adminGroupCards"
|
||||||
|
:new-player="newPlayer"
|
||||||
|
:assignable-groups="assignableGroups"
|
||||||
|
:players-sorted="playersSorted"
|
||||||
|
:player-filter="playerFilter"
|
||||||
|
:player-sort="playerSort"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:group-option-label="groupOptionLabel"
|
||||||
|
:normalized-group-code="normalizedGroupCode"
|
||||||
|
@update:group-setup-input="$emit('update:group-setup-input', $event)"
|
||||||
|
@save-group-setup="$emit('save-group-setup')"
|
||||||
|
@auto-group-even="$emit('auto-group-even')"
|
||||||
|
@update:new-player="$emit('update:new-player', $event)"
|
||||||
|
@create-player="$emit('create-player')"
|
||||||
|
@convert-new-name="$emit('convert-new-name', $event)"
|
||||||
|
@update:player-filter="$emit('update:player-filter', $event)"
|
||||||
|
@update:player-sort="$emit('update:player-sort', $event)"
|
||||||
|
@open-image-uploader="$emit('open-image-uploader', $event)"
|
||||||
|
@update-player-field="$emit('update-player-field', $event)"
|
||||||
|
@update-player-group="$emit('update-player-group', $event)"
|
||||||
|
@convert-row-name="$emit('convert-row-name', $event)"
|
||||||
|
@remove-player-image="$emit('remove-player-image', $event)"
|
||||||
|
@delete-player="$emit('delete-player', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section v-show="adminTab === 'liveMode'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('liveMode') }}</h2>
|
||||||
|
<p>{{ t('labels.liveModeVisibility') }}</p>
|
||||||
|
</div>
|
||||||
|
<LiveSettingsCard :t="t" :live-settings="liveSettings" :assignable-groups="assignableGroups" @update-live-setting="$emit('update-live-setting', $event)" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="adminTab === 'preliminary'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.preliminaryAdminTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.preliminaryAdminSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'preliminary')">{{ t('actions.resetScores') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage-filter-bar">
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
:value="scoreFilters.preliminary"
|
||||||
|
:placeholder="t('actions.searchPlayer')"
|
||||||
|
@input="$emit('update-score-filter', { stage: 'preliminary', value: $event.target.value })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MultiRoundScoreEditor
|
||||||
|
:t="t"
|
||||||
|
:rows="preliminaryScoreRows"
|
||||||
|
:round-stages="[
|
||||||
|
{ stage: 'prelim_r1', label: t('table.round1') },
|
||||||
|
{ stage: 'prelim_r2', label: t('table.round2') },
|
||||||
|
{ stage: 'prelim_r3', label: t('table.round3') },
|
||||||
|
]"
|
||||||
|
:total-score="preliminaryTotalScore"
|
||||||
|
:show-filter="false"
|
||||||
|
:filter-text="scoreFilters.preliminary"
|
||||||
|
:show-group="true"
|
||||||
|
:show-rank="false"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:display-name="displayName"
|
||||||
|
:secondary-name="secondaryName"
|
||||||
|
:score-input-value="scoreInputValue"
|
||||||
|
:on-score-focus="onScoreFocus"
|
||||||
|
:on-score-input="onScoreInput"
|
||||||
|
:on-score-commit="onScoreCommit"
|
||||||
|
:has-score-proof="hasScoreProof"
|
||||||
|
:score-proof-for="scoreProofFor"
|
||||||
|
:open-score-proof-uploader="openScoreProofUploader"
|
||||||
|
:open-proof-preview="openProofPreview"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="adminTab === 'prelimTie'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.prelimTieTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.prelimTieSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint-box" v-if="prelimTie.required">
|
||||||
|
{{ t('labels.tieSlots') }}: {{ prelimTie.slots }}
|
||||||
|
</div>
|
||||||
|
<div class="hint-box danger" v-if="prelimTie.required && !prelimTie.resolved">
|
||||||
|
{{ t('messages.prelimTieUnresolved') }}
|
||||||
|
</div>
|
||||||
|
<div class="empty-state good" v-if="!prelimTie.required">{{ t('messages.noPrelimTie') }}</div>
|
||||||
|
|
||||||
|
<template v-if="prelimTie.required">
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'prelim_tiebreak')">{{ t('actions.resetScores') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScoreStageEditor
|
||||||
|
:t="t"
|
||||||
|
stage="prelim_tiebreak"
|
||||||
|
color-mode="score"
|
||||||
|
:rows="prelimTieScoreRows"
|
||||||
|
:filter-text="scoreFilters.prelim_tiebreak"
|
||||||
|
:show-score-before-input="true"
|
||||||
|
:input-label="t('table.tieScore')"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:display-name="displayName"
|
||||||
|
:secondary-name="secondaryName"
|
||||||
|
:score-input-value="scoreInputValue"
|
||||||
|
:on-score-focus="onScoreFocus"
|
||||||
|
:on-score-input="onScoreInput"
|
||||||
|
:on-score-commit="onScoreCommit"
|
||||||
|
:has-score-proof="hasScoreProof"
|
||||||
|
:score-proof-for="scoreProofFor"
|
||||||
|
:open-score-proof-uploader="openScoreProofUploader"
|
||||||
|
:remove-score-proof="removeScoreProof"
|
||||||
|
:open-proof-preview="openProofPreview"
|
||||||
|
:request-score-advice="requestScoreAdvice"
|
||||||
|
@update:filter="$emit('update-score-filter', { stage: 'prelim_tiebreak', value: $event })"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="adminTab === 'final'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.finalAdminTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.finalAdminSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final')">{{ t('actions.resetScores') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage-filter-bar">
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
:value="scoreFilters.final_common"
|
||||||
|
:placeholder="t('actions.searchPlayer')"
|
||||||
|
@input="$emit('update-score-filter', { stage: 'final_common', value: $event.target.value })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MultiRoundScoreEditor
|
||||||
|
:t="t"
|
||||||
|
:rows="finalScoreRows"
|
||||||
|
:round-stages="[
|
||||||
|
{ stage: 'final_r1', label: t('table.round1') },
|
||||||
|
{ stage: 'final_r2', label: t('table.round2') },
|
||||||
|
]"
|
||||||
|
:total-score="finalTotalScore"
|
||||||
|
:show-filter="false"
|
||||||
|
:filter-text="scoreFilters.final_common"
|
||||||
|
:show-group="true"
|
||||||
|
:show-seed="true"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:display-name="displayName"
|
||||||
|
:secondary-name="secondaryName"
|
||||||
|
:score-input-value="scoreInputValue"
|
||||||
|
:on-score-focus="onScoreFocus"
|
||||||
|
:on-score-input="onScoreInput"
|
||||||
|
:on-score-commit="onScoreCommit"
|
||||||
|
:has-score-proof="hasScoreProof"
|
||||||
|
:score-proof-for="scoreProofFor"
|
||||||
|
:open-score-proof-uploader="openScoreProofUploader"
|
||||||
|
:open-proof-preview="openProofPreview"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="adminTab === 'finalTie'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.finalTieTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.finalTieSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint-box danger" v-if="finalTie.required && !finalTie.resolved">{{ t('messages.finalTieUnresolved') }}</div>
|
||||||
|
<div class="empty-state good" v-if="!finalTie.required">{{ t('messages.noFinalTie') }}</div>
|
||||||
|
|
||||||
|
<template v-if="finalTie.required">
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final_tiebreak')">{{ t('actions.resetScores') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScoreStageEditor
|
||||||
|
:t="t"
|
||||||
|
stage="final_tiebreak"
|
||||||
|
color-mode="score"
|
||||||
|
:rows="finalTieScoreRows"
|
||||||
|
:filter-text="scoreFilters.final_tiebreak"
|
||||||
|
:show-score-before-input="true"
|
||||||
|
:input-label="t('table.tieScore')"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:display-name="displayName"
|
||||||
|
:secondary-name="secondaryName"
|
||||||
|
:score-input-value="scoreInputValue"
|
||||||
|
:on-score-focus="onScoreFocus"
|
||||||
|
:on-score-input="onScoreInput"
|
||||||
|
:on-score-commit="onScoreCommit"
|
||||||
|
:has-score-proof="hasScoreProof"
|
||||||
|
:score-proof-for="scoreProofFor"
|
||||||
|
:open-score-proof-uploader="openScoreProofUploader"
|
||||||
|
:remove-score-proof="removeScoreProof"
|
||||||
|
:open-proof-preview="openProofPreview"
|
||||||
|
:request-score-advice="requestScoreAdvice"
|
||||||
|
@update:filter="$emit('update-score-filter', { stage: 'final_tiebreak', value: $event })"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<h3 class="sub-heading mt-32">{{ t('sections.finalRanking') }}</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.rank') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.score') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
<th>{{ t('table.medal') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in finalRows" :key="'a-fr-' + row.playerId" :class="{ 'podium-row': row.rank <= 3 }">
|
||||||
|
<td class="mono rank">{{ row.rank }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">{{ row.score }}</td>
|
||||||
|
<td class="mono">{{ row.tieBreak }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="row.rank === 1">🥇</span>
|
||||||
|
<span v-else-if="row.rank === 2">🥈</span>
|
||||||
|
<span v-else-if="row.rank === 3">🥉</span>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="finalRows.length === 0"><td colspan="5" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import PlayersManagementTab from './admin/PlayersManagementTab.vue'
|
||||||
|
import ScoreStageEditor from './admin/ScoreStageEditor.vue'
|
||||||
|
import LiveSettingsCard from './admin/LiveSettingsCard.vue'
|
||||||
|
import MultiRoundScoreEditor from './admin/MultiRoundScoreEditor.vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
adminTabs: { type: Array, required: true },
|
||||||
|
adminTab: { type: String, required: true },
|
||||||
|
viewProofInView: { type: Boolean, default: false },
|
||||||
|
liveSettings: { type: Object, required: true },
|
||||||
|
groupSetupInput: { type: String, default: '' },
|
||||||
|
adminGroupCards: { type: Array, required: true },
|
||||||
|
newPlayer: { type: Object, required: true },
|
||||||
|
assignableGroups: { type: Array, required: true },
|
||||||
|
playersSorted: { type: Array, required: true },
|
||||||
|
playerFilter: { type: String, default: '' },
|
||||||
|
playerSort: { type: String, default: 'id' },
|
||||||
|
playerImage: { type: Function, required: true },
|
||||||
|
groupOptionLabel: { type: Function, required: true },
|
||||||
|
normalizedGroupCode: { type: Function, required: true },
|
||||||
|
preliminaryScoreRows: { type: Array, required: true },
|
||||||
|
prelimTieScoreRows: { type: Array, required: true },
|
||||||
|
finalScoreRows: { type: Array, required: true },
|
||||||
|
finalTieScoreRows: { type: Array, required: true },
|
||||||
|
finalRows: { type: Array, required: true },
|
||||||
|
preliminaryTotalScore: { type: Function, required: true },
|
||||||
|
finalTotalScore: { type: Function, required: true },
|
||||||
|
prelimTie: { type: Object, required: true },
|
||||||
|
finalTie: { type: Object, required: true },
|
||||||
|
scoreFilters: { type: Object, required: true },
|
||||||
|
displayName: { type: Function, required: true },
|
||||||
|
secondaryName: { type: Function, required: true },
|
||||||
|
scoreInputValue: { type: Function, required: true },
|
||||||
|
onScoreFocus: { type: Function, required: true },
|
||||||
|
onScoreInput: { type: Function, required: true },
|
||||||
|
onScoreCommit: { type: Function, required: true },
|
||||||
|
hasScoreProof: { type: Function, required: true },
|
||||||
|
scoreProofFor: { type: Function, required: true },
|
||||||
|
openScoreProofUploader: { type: Function, required: true },
|
||||||
|
removeScoreProof: { type: Function, required: true },
|
||||||
|
openProofPreview: { type: Function, required: true },
|
||||||
|
requestScoreAdvice: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits([
|
||||||
|
'refresh',
|
||||||
|
'logout',
|
||||||
|
'toggle-view-proof',
|
||||||
|
'update-live-setting',
|
||||||
|
'change-admin-tab',
|
||||||
|
'update:group-setup-input',
|
||||||
|
'save-group-setup',
|
||||||
|
'auto-group-even',
|
||||||
|
'update:new-player',
|
||||||
|
'create-player',
|
||||||
|
'convert-new-name',
|
||||||
|
'update:player-filter',
|
||||||
|
'update:player-sort',
|
||||||
|
'open-image-uploader',
|
||||||
|
'update-player-field',
|
||||||
|
'update-player-group',
|
||||||
|
'convert-row-name',
|
||||||
|
'remove-player-image',
|
||||||
|
'delete-player',
|
||||||
|
'request-reset-stage',
|
||||||
|
'update-score-filter',
|
||||||
|
])
|
||||||
|
</script>
|
||||||
113
frontend/src/components/AppHeader.vue
Normal file
113
frontend/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<header class="masthead">
|
||||||
|
<div class="masthead-main">
|
||||||
|
<div class="masthead-brand">
|
||||||
|
<picture>
|
||||||
|
<source srcset="/logo.svg" type="image/svg+xml" />
|
||||||
|
<img class="masthead-logo" src="/logo.png" :alt="competitionTitle" />
|
||||||
|
</picture>
|
||||||
|
<p class="masthead-subtitle">{{ t('subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="masthead-controls">
|
||||||
|
<div ref="controlsRoot" class="controls-popup-anchor">
|
||||||
|
<div class="control-toggle-row">
|
||||||
|
<button class="control-toggle-btn" type="button" :aria-expanded="controlsOpen ? 'true' : 'false'" @click="toggleControls">
|
||||||
|
<span>{{ optionsLabel }}</span>
|
||||||
|
<span class="control-toggle-meta">{{ modeShort }} · {{ languageShort }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="controls-reveal">
|
||||||
|
<div v-if="controlsOpen" class="controls-grid controls-popover">
|
||||||
|
<div class="control-box">
|
||||||
|
<p class="control-label">{{ t('labels.mode') }}</p>
|
||||||
|
<div class="language-switcher mode-switcher">
|
||||||
|
<button class="lang-btn" :class="{ active: mode === 'view' }" @click="changeMode('view')">{{ t('viewMode') }}</button>
|
||||||
|
<button class="lang-btn" :class="{ active: mode === 'live' }" @click="changeMode('live')">{{ t('liveMode') }}</button>
|
||||||
|
<button class="lang-btn" :class="{ active: mode === 'admin' }" @click="changeMode('admin')">{{ t('adminMode') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-box">
|
||||||
|
<p class="control-label">{{ t('labels.language') }}</p>
|
||||||
|
<div class="language-switcher">
|
||||||
|
<button class="lang-btn" :class="{ active: language === 'ar' }" @click="changeLanguage('ar')">العربية</button>
|
||||||
|
<button class="lang-btn" :class="{ active: language === 'en' }" @click="changeLanguage('en')">English</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
<div class="status-row">
|
||||||
|
<div class="live-badge">{{ modeStatusLabel }}</div>
|
||||||
|
<div class="server-time" v-if="serverTime">{{ t('labels.lastSync') }}: {{ serverTime }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
competitionTitle: { type: String, required: true },
|
||||||
|
mode: { type: String, required: true },
|
||||||
|
language: { type: String, required: true },
|
||||||
|
serverTime: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['change-mode', 'change-language'])
|
||||||
|
|
||||||
|
const controlsOpen = ref(false)
|
||||||
|
const controlsRoot = ref(null)
|
||||||
|
|
||||||
|
const optionsLabel = computed(() => (props.language === 'ar' ? 'خيارات' : 'Options'))
|
||||||
|
const modeShort = computed(() => {
|
||||||
|
if (props.mode === 'view') return props.language === 'ar' ? 'عرض' : 'View'
|
||||||
|
if (props.mode === 'live') return props.language === 'ar' ? 'حي' : 'Live'
|
||||||
|
return props.language === 'ar' ? 'إدارة' : 'Admin'
|
||||||
|
})
|
||||||
|
const languageShort = computed(() => (props.language === 'ar' ? 'AR' : 'EN'))
|
||||||
|
const modeStatusLabel = computed(() => {
|
||||||
|
if (props.mode === 'view') return '● ' + props.t('labels.liveTracker')
|
||||||
|
if (props.mode === 'live') return '● ' + props.t('liveMode')
|
||||||
|
return props.t('adminPanel')
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleControls() {
|
||||||
|
controlsOpen.value = !controlsOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeMode(nextMode) {
|
||||||
|
emit('change-mode', nextMode)
|
||||||
|
controlsOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeLanguage(nextLanguage) {
|
||||||
|
emit('change-language', nextLanguage)
|
||||||
|
controlsOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalPointerDown(event) {
|
||||||
|
if (!controlsOpen.value) return
|
||||||
|
const root = controlsRoot.value
|
||||||
|
if (root && !root.contains(event.target)) controlsOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalKeyDown(event) {
|
||||||
|
if (event.key === 'Escape') controlsOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('pointerdown', onGlobalPointerDown)
|
||||||
|
window.addEventListener('keydown', onGlobalKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('pointerdown', onGlobalPointerDown)
|
||||||
|
window.removeEventListener('keydown', onGlobalKeyDown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
190
frontend/src/components/ImageCropModal.vue
Normal file
190
frontend/src/components/ImageCropModal.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="modal-card image-crop-modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>{{ t('sections.profileCropTitle') }}</h3>
|
||||||
|
<button class="btn btn-outline btn-xs" @click="$emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="modal-text subtle">{{ t('sections.profileCropSubtitle') }}</p>
|
||||||
|
|
||||||
|
<div class="crop-canvas-wrap">
|
||||||
|
<canvas
|
||||||
|
ref="canvasRef"
|
||||||
|
class="crop-canvas"
|
||||||
|
width="340"
|
||||||
|
height="340"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
@pointercancel="onPointerUp"
|
||||||
|
/>
|
||||||
|
<div class="crop-circle-guide" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="crop-controls">
|
||||||
|
<label>{{ t('labels.zoom') }}</label>
|
||||||
|
<input type="range" min="1" max="3" step="0.01" :value="zoom" @input="onZoomInput" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions split">
|
||||||
|
<button class="btn btn-outline" @click="resetPosition">{{ t('actions.resetPosition') }}</button>
|
||||||
|
<button class="btn btn-secondary" @click="$emit('close')">{{ t('actions.cancel') }}</button>
|
||||||
|
<button class="btn btn-primary" @click="confirmCrop">{{ t('actions.applyCrop') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
sourceImage: { type: String, default: '' },
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'confirm'])
|
||||||
|
|
||||||
|
const canvasRef = ref(null)
|
||||||
|
const zoom = ref(1)
|
||||||
|
const image = new Image()
|
||||||
|
const state = reactive({
|
||||||
|
loaded: false,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
dragging: false,
|
||||||
|
pointerId: null,
|
||||||
|
lastX: 0,
|
||||||
|
lastY: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const CANVAS_SIZE = 340
|
||||||
|
const EXPORT_SIZE = 520
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.open, props.sourceImage],
|
||||||
|
async ([open, src]) => {
|
||||||
|
if (!open || !src) return
|
||||||
|
await loadImage(src)
|
||||||
|
resetPosition()
|
||||||
|
drawPreview()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(zoom, () => {
|
||||||
|
clampOffsets()
|
||||||
|
drawPreview()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
drawPreview()
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadImage(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
image.onload = () => {
|
||||||
|
state.loaded = true
|
||||||
|
state.width = image.naturalWidth || image.width
|
||||||
|
state.height = image.naturalHeight || image.height
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
image.onerror = () => reject(new Error('failed to load image'))
|
||||||
|
image.src = src
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onZoomInput(event) {
|
||||||
|
zoom.value = Number(event.target.value || 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPosition() {
|
||||||
|
zoom.value = 1
|
||||||
|
state.offsetX = 0
|
||||||
|
state.offsetY = 0
|
||||||
|
drawPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDrawMetrics(size) {
|
||||||
|
const baseScale = Math.max(size / state.width, size / state.height)
|
||||||
|
const scale = baseScale * zoom.value
|
||||||
|
const width = state.width * scale
|
||||||
|
const height = state.height * scale
|
||||||
|
const left = (size-width)/2 + state.offsetX
|
||||||
|
const top = (size-height)/2 + state.offsetY
|
||||||
|
return { width, height, left, top }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampOffsets() {
|
||||||
|
if (!state.loaded) return
|
||||||
|
const { width, height } = getDrawMetrics(CANVAS_SIZE)
|
||||||
|
const maxOffsetX = Math.max(0, (width - CANVAS_SIZE) / 2)
|
||||||
|
const maxOffsetY = Math.max(0, (height - CANVAS_SIZE) / 2)
|
||||||
|
if (state.offsetX > maxOffsetX) state.offsetX = maxOffsetX
|
||||||
|
if (state.offsetX < -maxOffsetX) state.offsetX = -maxOffsetX
|
||||||
|
if (state.offsetY > maxOffsetY) state.offsetY = maxOffsetY
|
||||||
|
if (state.offsetY < -maxOffsetY) state.offsetY = -maxOffsetY
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPreview() {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE)
|
||||||
|
ctx.fillStyle = '#f0f2f8'
|
||||||
|
ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE)
|
||||||
|
|
||||||
|
if (!state.loaded) return
|
||||||
|
const { width, height, left, top } = getDrawMetrics(CANVAS_SIZE)
|
||||||
|
ctx.drawImage(image, left, top, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event) {
|
||||||
|
if (!state.loaded) return
|
||||||
|
state.dragging = true
|
||||||
|
state.pointerId = event.pointerId
|
||||||
|
state.lastX = event.clientX
|
||||||
|
state.lastY = event.clientY
|
||||||
|
event.target.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event) {
|
||||||
|
if (!state.dragging || state.pointerId !== event.pointerId) return
|
||||||
|
const dx = event.clientX - state.lastX
|
||||||
|
const dy = event.clientY - state.lastY
|
||||||
|
state.lastX = event.clientX
|
||||||
|
state.lastY = event.clientY
|
||||||
|
state.offsetX += dx
|
||||||
|
state.offsetY += dy
|
||||||
|
clampOffsets()
|
||||||
|
drawPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(event) {
|
||||||
|
if (state.pointerId !== event.pointerId) return
|
||||||
|
state.dragging = false
|
||||||
|
state.pointerId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCrop() {
|
||||||
|
if (!state.loaded) return
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = EXPORT_SIZE
|
||||||
|
canvas.height = EXPORT_SIZE
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const { width, height, left, top } = getDrawMetrics(EXPORT_SIZE)
|
||||||
|
ctx.fillStyle = '#f0f2f8'
|
||||||
|
ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE)
|
||||||
|
ctx.drawImage(image, left, top, width, height)
|
||||||
|
emit('confirm', canvas.toDataURL('image/jpeg', 0.9))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
475
frontend/src/components/LiveModePanel.vue
Normal file
475
frontend/src/components/LiveModePanel.vue
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
<template>
|
||||||
|
<section class="live-mode-shell">
|
||||||
|
<div class="live-mode-overlay">
|
||||||
|
<header class="live-mode-header">
|
||||||
|
<h2>{{ t('sections.liveModeTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.liveModeSubtitle') }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="live-screen-head live-head-selection">
|
||||||
|
<span class="live-chip strong">{{ activeViewLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-mode-grid single">
|
||||||
|
<article v-if="activeView === 'group_live'" class="live-screen-card wide">
|
||||||
|
<div class="live-screen-head">
|
||||||
|
<h3>{{ t('sections.liveTitle') }}</h3>
|
||||||
|
<div class="live-chip-row">
|
||||||
|
<span class="live-chip">{{ liveSettings.groupDisplayMode === 'fixed' ? t('actions.liveModeFixed') : t('actions.liveModeRotate') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="live-swap" mode="out-in">
|
||||||
|
<div :key="'live-group-swap-' + (liveGroupCode || 'u')" class="live-swap-block">
|
||||||
|
<div class="live-focus-banner" :class="'focus-' + groupClassKey">
|
||||||
|
<span class="live-focus-label">{{ t('labels.group') }}</span>
|
||||||
|
<strong class="live-focus-title">{{ liveGroupCode || t('labels.unassigned') }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table live-score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.rank') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.round1') }}</th>
|
||||||
|
<th>{{ t('table.round2') }}</th>
|
||||||
|
<th>{{ t('table.round3') }}</th>
|
||||||
|
<th>{{ t('table.total') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="entry in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id">
|
||||||
|
<td class="mono rank">{{ entry.rank }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(entry.player)" :alt="entry.player.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(entry.player) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(entry.player) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ scoreFor('prelim_r1', entry.player.id) }}</td>
|
||||||
|
<td class="mono">{{ scoreFor('prelim_r2', entry.player.id) }}</td>
|
||||||
|
<td class="mono">{{ scoreFor('prelim_r3', entry.player.id) }}</td>
|
||||||
|
<td class="mono strong">{{ entry.total }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="liveGroupMembers.length === 0"><td colspan="6" class="muted center">{{ t('labels.emptyLive') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-else-if="activeView === 'prelim_tie'" class="live-screen-card wide">
|
||||||
|
<div class="live-screen-head">
|
||||||
|
<h3>{{ t('sections.prelimTieTitle') }}</h3>
|
||||||
|
<div class="live-chip-row">
|
||||||
|
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
||||||
|
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="live-swap" mode="out-in">
|
||||||
|
<div :key="'prelim-tie-page-' + listPageIndex" class="live-swap-block">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, index) in pagedPrelimTieRows" :key="'live-pre-tie-' + row.playerId">
|
||||||
|
<td class="mono">{{ listPageStart + index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">{{ row.tieBreak }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="prelimTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noPrelimTie') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-else-if="activeView === 'prelim_overall'" class="live-screen-card wide">
|
||||||
|
<div class="live-screen-head">
|
||||||
|
<h3>{{ t('sections.overallTitle') }}</h3>
|
||||||
|
<div class="live-chip-row">
|
||||||
|
<span class="live-chip strong">{{ t('labels.top12') }}: {{ finalists.length }}</span>
|
||||||
|
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="live-swap" mode="out-in">
|
||||||
|
<div :key="'prelim-overall-page-' + listPageIndex" class="live-swap-block">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.rank') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.total') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in pagedPreliminaryRows"
|
||||||
|
:key="'live-pre-overall-' + row.playerId"
|
||||||
|
:class="{ 'qualified-row': isFinalist(row.playerId) }"
|
||||||
|
>
|
||||||
|
<td class="mono rank">{{ row.rank }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">{{ row.score }}</td>
|
||||||
|
<td class="mono">{{ row.tieBreak }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="preliminaryRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-else-if="activeView === 'final_groups'" class="live-screen-card wide">
|
||||||
|
<div class="live-screen-head">
|
||||||
|
<h3>{{ t('sections.finalTitle') }}</h3>
|
||||||
|
<div class="live-chip-row">
|
||||||
|
<span class="live-chip">{{ liveSettings.finalDisplayMode === 'fixed' ? t('actions.liveModeFixed') : t('actions.liveModeRotate') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="live-swap" mode="out-in">
|
||||||
|
<div :key="'live-final-swap-' + liveFinalGroup" class="live-swap-block">
|
||||||
|
<div class="live-focus-banner focus-final">
|
||||||
|
<span class="live-focus-label">{{ t('tabs.final') }}</span>
|
||||||
|
<strong class="live-focus-title">{{ currentFinalGroupLabel }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table live-score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.rank') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.round1') }}</th>
|
||||||
|
<th>{{ t('table.round2') }}</th>
|
||||||
|
<th>{{ t('table.total') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="entry in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId">
|
||||||
|
<td class="mono rank">{{ entry.rank }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(entry.row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(entry.row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ scoreFor('final_r1', entry.row.playerId) }}</td>
|
||||||
|
<td class="mono">{{ scoreFor('final_r2', entry.row.playerId) }}</td>
|
||||||
|
<td class="mono strong">{{ entry.total }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="liveFinalRows.length === 0"><td colspan="5" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-else-if="activeView === 'final_tie'" class="live-screen-card wide">
|
||||||
|
<div class="live-screen-head">
|
||||||
|
<h3>{{ t('sections.finalTieTitle') }}</h3>
|
||||||
|
<div class="live-chip-row">
|
||||||
|
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
||||||
|
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="live-swap" mode="out-in">
|
||||||
|
<div :key="'final-tie-page-' + listPageIndex" class="live-swap-block">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, index) in pagedFinalTieRows" :key="'live-final-tie-' + row.playerId">
|
||||||
|
<td class="mono">{{ listPageStart + index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">{{ row.tieBreak }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="finalTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noFinalTie') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-else class="live-screen-card podium-live-card wide">
|
||||||
|
<div class="live-screen-head">
|
||||||
|
<h3>{{ t('sections.podiumTitle') }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="podium-wrapper live-podium">
|
||||||
|
<article class="podium-col pos-2">
|
||||||
|
<div class="podium-avatar-wrap">
|
||||||
|
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
|
||||||
|
</div>
|
||||||
|
<div class="podium-medal-icon">🥈</div>
|
||||||
|
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
|
||||||
|
<div class="podium-score-wrap">
|
||||||
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
|
||||||
|
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="podium-col pos-1">
|
||||||
|
<div class="podium-avatar-wrap">
|
||||||
|
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
|
||||||
|
</div>
|
||||||
|
<div class="podium-medal-icon">🥇</div>
|
||||||
|
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
|
||||||
|
<div class="podium-score-wrap">
|
||||||
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
|
||||||
|
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="podium-col pos-3">
|
||||||
|
<div class="podium-avatar-wrap">
|
||||||
|
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
|
||||||
|
</div>
|
||||||
|
<div class="podium-medal-icon">🥉</div>
|
||||||
|
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
|
||||||
|
<div class="podium-score-wrap">
|
||||||
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
|
||||||
|
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
liveSettings: { type: Object, required: true },
|
||||||
|
liveGroupCode: { type: String, default: '' },
|
||||||
|
liveGroupMembers: { type: Array, required: true },
|
||||||
|
prelimTieRows: { type: Array, required: true },
|
||||||
|
preliminaryRows: { type: Array, required: true },
|
||||||
|
finalists: { type: Array, required: true },
|
||||||
|
liveFinalGroup: { type: Number, required: true },
|
||||||
|
liveFinalRows: { type: Array, required: true },
|
||||||
|
finalTieRows: { type: Array, required: true },
|
||||||
|
podiumOrdered: { type: Array, required: true },
|
||||||
|
playerImage: { type: Function, required: true },
|
||||||
|
displayName: { type: Function, required: true },
|
||||||
|
secondaryName: { type: Function, required: true },
|
||||||
|
scoreFor: { type: Function, required: true },
|
||||||
|
prelimTotal: { type: Function, required: true },
|
||||||
|
finalTotal: { type: Function, required: true },
|
||||||
|
podiumImage: { type: Function, required: true },
|
||||||
|
podiumName: { type: Function, required: true },
|
||||||
|
podiumHasResult: { type: Function, required: true },
|
||||||
|
podiumScoreMain: { type: Function, required: true },
|
||||||
|
podiumTieSubtitle: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalistIds = computed(() => new Set((props.finalists || []).map((row) => row.playerId)))
|
||||||
|
const activeView = computed(() => props.liveSettings.activeView || 'group_live')
|
||||||
|
const listPageIndex = ref(0)
|
||||||
|
let listRotationTimer = null
|
||||||
|
|
||||||
|
const rotationIntervalSec = computed(() => {
|
||||||
|
const raw = Number(props.liveSettings.rotationIntervalSec || 5)
|
||||||
|
if (!Number.isFinite(raw)) return 5
|
||||||
|
return Math.max(3, raw)
|
||||||
|
})
|
||||||
|
|
||||||
|
const rotationPlayerCount = computed(() => {
|
||||||
|
const raw = Number(props.liveSettings.rotationPlayerCount || 12)
|
||||||
|
if (!Number.isFinite(raw)) return 12
|
||||||
|
if (raw < 3) return 3
|
||||||
|
if (raw > 40) return 40
|
||||||
|
return Math.floor(raw)
|
||||||
|
})
|
||||||
|
|
||||||
|
const listRowsForActiveView = computed(() => {
|
||||||
|
if (activeView.value === 'prelim_tie') return props.prelimTieRows || []
|
||||||
|
if (activeView.value === 'prelim_overall') return props.preliminaryRows || []
|
||||||
|
if (activeView.value === 'final_tie') return props.finalTieRows || []
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const listPageCount = computed(() => {
|
||||||
|
const total = listRowsForActiveView.value.length
|
||||||
|
if (total <= 0) return 1
|
||||||
|
return Math.max(1, Math.ceil(total / rotationPlayerCount.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const listPageStart = computed(() => listPageIndex.value * rotationPlayerCount.value)
|
||||||
|
const currentListPageDisplay = computed(() => `${listPageIndex.value + 1} / ${listPageCount.value}`)
|
||||||
|
|
||||||
|
const pagedPrelimTieRows = computed(() => paginateRows(props.prelimTieRows || []))
|
||||||
|
const pagedPreliminaryRows = computed(() => paginateRows(props.preliminaryRows || []))
|
||||||
|
const pagedFinalTieRows = computed(() => paginateRows(props.finalTieRows || []))
|
||||||
|
const rankedLiveGroupMembers = computed(() => {
|
||||||
|
const rows = (props.liveGroupMembers || []).map((player) => ({
|
||||||
|
player,
|
||||||
|
total: Number(props.prelimTotal(player.id) || 0),
|
||||||
|
}))
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
if (a.total !== b.total) return b.total - a.total
|
||||||
|
return a.player.id - b.player.id
|
||||||
|
})
|
||||||
|
|
||||||
|
let prevTotal = null
|
||||||
|
let prevRank = 0
|
||||||
|
return rows.map((entry, idx) => {
|
||||||
|
const rank = prevTotal === entry.total ? prevRank : idx + 1
|
||||||
|
prevTotal = entry.total
|
||||||
|
prevRank = rank
|
||||||
|
return { ...entry, rank }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeViewLabel = computed(() => {
|
||||||
|
const map = {
|
||||||
|
group_live: props.t('labels.liveViewGroup'),
|
||||||
|
prelim_tie: props.t('labels.liveViewPrelimTie'),
|
||||||
|
prelim_overall: props.t('labels.liveViewPrelimOverall'),
|
||||||
|
final_groups: props.t('labels.liveViewFinalGroups'),
|
||||||
|
final_tie: props.t('labels.liveViewFinalTie'),
|
||||||
|
podium: props.t('labels.liveViewPodium'),
|
||||||
|
}
|
||||||
|
return map[activeView.value] || map.group_live
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentFinalGroupLabel = computed(() => {
|
||||||
|
if (props.liveFinalGroup === 2) return props.t('labels.finalGroup2')
|
||||||
|
return props.t('labels.finalGroup1')
|
||||||
|
})
|
||||||
|
const groupClassKey = computed(() => {
|
||||||
|
const code = String(props.liveGroupCode || '').trim().toUpperCase()
|
||||||
|
if (code.startsWith('A')) return 'a'
|
||||||
|
if (code.startsWith('B')) return 'b'
|
||||||
|
if (code.startsWith('C')) return 'c'
|
||||||
|
if (code.startsWith('D')) return 'd'
|
||||||
|
return 'u'
|
||||||
|
})
|
||||||
|
const rankedLiveFinalRows = computed(() => {
|
||||||
|
const rows = (props.liveFinalRows || []).map((row) => ({
|
||||||
|
row,
|
||||||
|
total: Number(props.finalTotal(row.playerId) || 0),
|
||||||
|
}))
|
||||||
|
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
if (a.total !== b.total) return b.total - a.total
|
||||||
|
const seedA = Number(a.row.seed || 0)
|
||||||
|
const seedB = Number(b.row.seed || 0)
|
||||||
|
if (seedA !== seedB) return seedA - seedB
|
||||||
|
return a.row.playerId - b.row.playerId
|
||||||
|
})
|
||||||
|
|
||||||
|
let prevTotal = null
|
||||||
|
let prevRank = 0
|
||||||
|
return rows.map((entry, idx) => {
|
||||||
|
const rank = prevTotal === entry.total ? prevRank : idx + 1
|
||||||
|
prevTotal = entry.total
|
||||||
|
prevRank = rank
|
||||||
|
return { ...entry, rank }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function isFinalist(playerId) {
|
||||||
|
return finalistIds.value.has(playerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() =>
|
||||||
|
`${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalTieRows.length}`,
|
||||||
|
() => {
|
||||||
|
restartListRotation()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopListRotation()
|
||||||
|
})
|
||||||
|
|
||||||
|
function paginateRows(rows) {
|
||||||
|
const start = listPageIndex.value * rotationPlayerCount.value
|
||||||
|
return rows.slice(start, start + rotationPlayerCount.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartListRotation() {
|
||||||
|
stopListRotation()
|
||||||
|
listPageIndex.value = 0
|
||||||
|
|
||||||
|
const autoRotateView = activeView.value === 'prelim_tie' || activeView.value === 'prelim_overall' || activeView.value === 'final_tie'
|
||||||
|
if (!autoRotateView) return
|
||||||
|
if (listPageCount.value <= 1) return
|
||||||
|
|
||||||
|
listRotationTimer = setInterval(() => {
|
||||||
|
if (listPageCount.value <= 1) {
|
||||||
|
listPageIndex.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listPageIndex.value = (listPageIndex.value + 1) % listPageCount.value
|
||||||
|
}, rotationIntervalSec.value * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopListRotation() {
|
||||||
|
if (listRotationTimer) {
|
||||||
|
clearInterval(listRotationTimer)
|
||||||
|
listRotationTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
24
frontend/src/components/ProofModal.vue
Normal file
24
frontend/src/components/ProofModal.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="modal-card proof-modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
<button class="btn btn-outline btn-xs" @click="$emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="proof-modal-image-wrap">
|
||||||
|
<img class="proof-modal-image" :src="image" :alt="title" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
image: { type: String, default: '' },
|
||||||
|
title: { type: String, default: 'Proof' },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['close'])
|
||||||
|
</script>
|
||||||
|
|
||||||
24
frontend/src/components/ResetStageModal.vue
Normal file
24
frontend/src/components/ResetStageModal.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-overlay" @click.self="$emit('cancel')">
|
||||||
|
<div class="modal-card">
|
||||||
|
<h3 class="modal-title">{{ t('actions.resetScores') }}</h3>
|
||||||
|
<p class="modal-text">{{ t('messages.confirmReset') }}</p>
|
||||||
|
<p class="modal-text subtle">{{ t('messages.resetProofPrompt') }}</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-outline" @click="$emit('confirm', false)">{{ t('actions.resetOnlyScores') }}</button>
|
||||||
|
<button class="btn btn-danger" @click="$emit('confirm', true)">{{ t('actions.resetScoresAndProofs') }}</button>
|
||||||
|
<button class="btn btn-secondary" @click="$emit('cancel')">{{ t('actions.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['cancel', 'confirm'])
|
||||||
|
</script>
|
||||||
|
|
||||||
61
frontend/src/components/ScoreAdviceModal.vue
Normal file
61
frontend/src/components/ScoreAdviceModal.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="modal-card ai-advice-modal compact">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>{{ t('sections.aiAdvisorTitle') }}</h3>
|
||||||
|
<button class="btn btn-outline btn-xs" @click="$emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-modal-body compact">
|
||||||
|
<div class="ai-image-wrap">
|
||||||
|
<img v-if="image" class="ai-proof-image" :src="image" :alt="t('table.verification')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-info-panel">
|
||||||
|
<template v-if="loading">
|
||||||
|
<div class="loading-state ai-loading-state">
|
||||||
|
<div class="spinner" />
|
||||||
|
<p>{{ t('messages.aiAnalyzing') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="error">
|
||||||
|
<div class="hint-box danger">{{ error }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="advice">
|
||||||
|
<div class="ai-metric-grid">
|
||||||
|
<div class="ai-metric-card">
|
||||||
|
<p class="ai-label">{{ t('labels.currentScore') }}</p>
|
||||||
|
<p class="ai-value mono">{{ currentScore }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="ai-metric-card highlight">
|
||||||
|
<p class="ai-label">{{ t('labels.aiSuggestedScore') }}</p>
|
||||||
|
<p class="ai-value mono">{{ advice.advisedScore }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="ai-summary">{{ advice.reason }}</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions split">
|
||||||
|
<button class="btn btn-outline" :disabled="loading" @click="$emit('refresh')">{{ t('actions.reAnalyze') }}</button>
|
||||||
|
<button class="btn btn-secondary" @click="$emit('close')">{{ t('actions.cancel') }}</button>
|
||||||
|
<button class="btn btn-ai" :disabled="loading || !advice" @click="$emit('apply')">{{ t('actions.quickApplyAi') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
advice: { type: Object, default: null },
|
||||||
|
currentScore: { type: Number, default: 0 },
|
||||||
|
image: { type: String, default: '' },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
error: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['close', 'refresh', 'apply'])
|
||||||
|
</script>
|
||||||
477
frontend/src/components/ViewModePanel.vue
Normal file
477
frontend/src/components/ViewModePanel.vue
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<nav class="tab-bar">
|
||||||
|
<button v-for="tab in viewTabs" :key="tab.id" class="tab-btn" :class="{ active: viewTab === tab.id }" @click="$emit('change-tab', tab.id)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section v-show="viewTab === 'groups'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.groupsTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.groupsSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-grid">
|
||||||
|
<article v-for="group in groupSummaries" :key="group.code" class="summary-card" :class="'group-' + group.key">
|
||||||
|
<h3>{{ t('labels.group') }} {{ group.code }}</h3>
|
||||||
|
<p class="summary-value">{{ group.count }}</p>
|
||||||
|
<p class="summary-status ok">{{ t('labels.players') }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="groupedPlayers.length > 0" class="view-group-grid">
|
||||||
|
<article v-for="group in groupedPlayers" :key="'view-group-' + group.code" class="view-group-card" :class="'group-' + group.key">
|
||||||
|
<div class="view-group-card-head">
|
||||||
|
<h3>{{ groupLabel(group.code) }}</h3>
|
||||||
|
<span class="pm-count-badge">{{ group.players.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="player in group.players" :key="'v-g-' + group.code + '-' + player.id" :class="rowClass(player.groupCode)">
|
||||||
|
<td class="mono">{{ player.id }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(player)" :alt="player.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(player) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(player) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div v-else class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="muted center">{{ t('labels.noPlayers') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="viewTab === 'live'" class="panel live-panel">
|
||||||
|
<div class="live-corner">
|
||||||
|
<div class="live-top-controls">
|
||||||
|
<button class="btn btn-outline light" :class="{ active: liveMode === 'rotate' }" @click="$emit('change-live-mode', 'rotate')">
|
||||||
|
{{ t('actions.liveRotate') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline light" :class="{ active: liveMode === 'fixed' }" @click="$emit('change-live-mode', 'fixed')">
|
||||||
|
{{ t('actions.liveFixed') }}
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
v-if="liveMode === 'fixed'"
|
||||||
|
class="group-select"
|
||||||
|
:value="selectedLiveGroup"
|
||||||
|
@change="$emit('change-live-group', $event.target.value)"
|
||||||
|
>
|
||||||
|
<option v-for="group in liveSelectableGroups" :key="'live-group-' + group" :value="group">{{ group }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="live-title">{{ t('sections.liveTitle') }} · {{ liveGroupCode || t('labels.unassigned') }}</div>
|
||||||
|
<p class="live-subtitle">{{ t('sections.liveSubtitle') }}</p>
|
||||||
|
|
||||||
|
<div class="live-grid" v-if="liveMembers.length > 0">
|
||||||
|
<article class="live-card" v-for="member in liveMembers" :key="'live-' + member.id">
|
||||||
|
<img class="live-image" :src="playerImage(member)" :alt="member.nameAr" />
|
||||||
|
<div>
|
||||||
|
<p class="live-number">#{{ member.id }}</p>
|
||||||
|
<p class="live-name-primary">{{ displayName(member) }}</p>
|
||||||
|
<p class="live-name-secondary">{{ secondaryName(member) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state" v-else>{{ t('labels.emptyLive') }}</div>
|
||||||
|
|
||||||
|
<div class="live-progress" :key="'tick-' + liveTick" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="viewTab === 'overall'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.overallTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.overallSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<div>
|
||||||
|
<h3 class="sub-heading">{{ t('labels.allPlayers') }}</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.rank') }}</th>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.group') }}</th>
|
||||||
|
<th>{{ t('table.score') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
<th>{{ t('table.status') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in preliminaryRows" :key="'v-pr-' + row.playerId" :class="{ 'qualified-row': isFinalist(row.playerId) }">
|
||||||
|
<td class="mono rank">{{ row.rank }}</td>
|
||||||
|
<td class="mono">{{ row.playerId }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
|
||||||
|
<td class="mono strong">
|
||||||
|
<span>{{ row.score }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && preliminaryProofStage(row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: preliminaryProofStage(row.playerId), playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor(preliminaryProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="mono">
|
||||||
|
<span>{{ row.tieBreak }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && hasScoreProof('prelim_tiebreak', row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: 'prelim_tiebreak', playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor('prelim_tiebreak', row.playerId)" :alt="t('table.verification')" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="isFinalist(row.playerId)" class="badge success">{{ t('labels.finalist') }}</span>
|
||||||
|
<span v-else class="muted">{{ t('labels.notFinalist') }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="preliminaryRows.length === 0"><td colspan="7" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="sub-heading">{{ t('labels.top12') }}</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.seed') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.group') }}</th>
|
||||||
|
<th>{{ t('table.score') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in finalists" :key="'v-top-' + row.playerId" class="qualified-row">
|
||||||
|
<td class="mono rank">{{ row.seed }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
|
||||||
|
<td class="mono strong">{{ row.score }}</td>
|
||||||
|
<td class="mono">{{ row.tieBreak }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="finalists.length === 0"><td colspan="5" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint-box danger" v-if="prelimTie.required && !prelimTie.resolved">
|
||||||
|
{{ t('messages.prelimTieUnresolved') }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="viewTab === 'final'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.finalTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.finalSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<div>
|
||||||
|
<h3 class="sub-heading">{{ t('labels.finalGroup1') }}</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.seed') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.score') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in finalGroup1" :key="'v-f1-' + row.playerId">
|
||||||
|
<td class="mono">{{ row.seed }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">
|
||||||
|
<span>{{ finalTotalScore(row.playerId) }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && finalProofStage(row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: finalProofStage(row.playerId), playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor(finalProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="finalGroup1.length === 0"><td colspan="3" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="sub-heading">{{ t('labels.finalGroup2') }}</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.seed') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.score') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in finalGroup2" :key="'v-f2-' + row.playerId">
|
||||||
|
<td class="mono">{{ row.seed }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">
|
||||||
|
<span>{{ row.score }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && finalProofStage(row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: finalProofStage(row.playerId), playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor(finalProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="finalGroup2.length === 0"><td colspan="3" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="sub-heading mt-32">{{ t('sections.finalRanking') }}</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.rank') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.score') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in finalRows" :key="'v-fr-' + row.playerId" :class="{ 'podium-row': row.rank <= 3 }">
|
||||||
|
<td class="mono rank">{{ row.rank }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">
|
||||||
|
<span>{{ row.score }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && finalProofStage(row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: finalProofStage(row.playerId), playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor(finalProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="mono">
|
||||||
|
<span>{{ row.tieBreak }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && hasScoreProof('final_tiebreak', row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: 'final_tiebreak', playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor('final_tiebreak', row.playerId)" :alt="t('table.verification')" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="finalRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint-box danger" v-if="finalTie.required && !finalTie.resolved">
|
||||||
|
{{ t('messages.finalTieUnresolved') }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="viewTab === 'podium'" class="panel podium-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.podiumTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.podiumSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="podium-wrapper">
|
||||||
|
<article class="podium-col pos-2">
|
||||||
|
<div class="podium-avatar-wrap">
|
||||||
|
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
|
||||||
|
</div>
|
||||||
|
<div class="podium-medal-icon">🥈</div>
|
||||||
|
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
|
||||||
|
<div class="podium-score-wrap">
|
||||||
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
|
||||||
|
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="podium-col pos-1">
|
||||||
|
<div class="podium-avatar-wrap">
|
||||||
|
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
|
||||||
|
</div>
|
||||||
|
<div class="podium-medal-icon">🥇</div>
|
||||||
|
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
|
||||||
|
<div class="podium-score-wrap">
|
||||||
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
|
||||||
|
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="podium-col pos-3">
|
||||||
|
<div class="podium-avatar-wrap">
|
||||||
|
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
|
||||||
|
</div>
|
||||||
|
<div class="podium-medal-icon">🥉</div>
|
||||||
|
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
|
||||||
|
<div class="podium-score-wrap">
|
||||||
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
|
||||||
|
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
viewTabs: { type: Array, required: true },
|
||||||
|
viewTab: { type: String, required: true },
|
||||||
|
groupSummaries: { type: Array, required: true },
|
||||||
|
playersSorted: { type: Array, required: true },
|
||||||
|
rowClass: { type: Function, required: true },
|
||||||
|
playerImage: { type: Function, required: true },
|
||||||
|
displayName: { type: Function, required: true },
|
||||||
|
secondaryName: { type: Function, required: true },
|
||||||
|
liveMode: { type: String, required: true },
|
||||||
|
selectedLiveGroup: { type: String, default: '' },
|
||||||
|
liveSelectableGroups: { type: Array, required: true },
|
||||||
|
liveGroupCode: { type: String, default: '' },
|
||||||
|
liveMembers: { type: Array, required: true },
|
||||||
|
liveTick: { type: Number, required: true },
|
||||||
|
preliminaryRows: { type: Array, required: true },
|
||||||
|
finalists: { type: Array, required: true },
|
||||||
|
isFinalist: { type: Function, required: true },
|
||||||
|
prelimTie: { type: Object, required: true },
|
||||||
|
finalGroup1: { type: Array, required: true },
|
||||||
|
finalGroup2: { type: Array, required: true },
|
||||||
|
finalRows: { type: Array, required: true },
|
||||||
|
finalTie: { type: Object, required: true },
|
||||||
|
finalTotalScore: { type: Function, required: true },
|
||||||
|
preliminaryProofStage: { type: Function, required: true },
|
||||||
|
finalProofStage: { type: Function, required: true },
|
||||||
|
podiumOrdered: { type: Array, required: true },
|
||||||
|
podiumImage: { type: Function, required: true },
|
||||||
|
podiumName: { type: Function, required: true },
|
||||||
|
podiumHasResult: { type: Function, required: true },
|
||||||
|
podiumScoreMain: { type: Function, required: true },
|
||||||
|
podiumTieSubtitle: { type: Function, required: true },
|
||||||
|
canViewProofs: { type: Boolean, default: false },
|
||||||
|
hasScoreProof: { type: Function, required: true },
|
||||||
|
scoreProofFor: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedPlayers = computed(() => {
|
||||||
|
const map = new Map()
|
||||||
|
const unassigned = props.t('labels.unassigned')
|
||||||
|
for (const player of props.playersSorted) {
|
||||||
|
const code = (player.groupCode || '').trim() || unassigned
|
||||||
|
if (!map.has(code)) map.set(code, [])
|
||||||
|
map.get(code).push(player)
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a[0] === unassigned) return 1
|
||||||
|
if (b[0] === unassigned) return -1
|
||||||
|
return String(a[0]).localeCompare(String(b[0]), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
})
|
||||||
|
.map(([code, players]) => ({
|
||||||
|
code,
|
||||||
|
key: resolveGroupKey(code, unassigned),
|
||||||
|
players: [...players].sort((p1, p2) => p1.id - p2.id),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function resolveGroupKey(code, unassigned) {
|
||||||
|
if (code === unassigned) return 'u'
|
||||||
|
const normalized = String(code || '').trim().toUpperCase()
|
||||||
|
if (normalized.startsWith('A')) return 'a'
|
||||||
|
if (normalized.startsWith('B')) return 'b'
|
||||||
|
if (normalized.startsWith('C')) return 'c'
|
||||||
|
if (normalized.startsWith('D')) return 'd'
|
||||||
|
return 'u'
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLabel(code) {
|
||||||
|
const unassigned = props.t('labels.unassigned')
|
||||||
|
if (code === unassigned) return unassigned
|
||||||
|
return `${props.t('labels.group')} ${code}`
|
||||||
|
}
|
||||||
|
|
||||||
|
defineEmits(['change-tab', 'change-live-mode', 'change-live-group', 'open-proof'])
|
||||||
|
</script>
|
||||||
95
frontend/src/components/admin/LiveSettingsCard.vue
Normal file
95
frontend/src/components/admin/LiveSettingsCard.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<section class="live-settings-card">
|
||||||
|
<div class="live-settings-head">
|
||||||
|
<h3>{{ t('labels.liveModeVisibility') }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-settings-row">
|
||||||
|
<label class="control-label">{{ t('labels.activeLiveView') }}</label>
|
||||||
|
<select class="name-input" :value="liveSettings.activeView" @change="emitPatch({ activeView: $event.target.value })">
|
||||||
|
<option value="group_live">{{ t('labels.liveViewGroup') }}</option>
|
||||||
|
<option value="prelim_tie">{{ t('labels.liveViewPrelimTie') }}</option>
|
||||||
|
<option value="prelim_overall">{{ t('labels.liveViewPrelimOverall') }}</option>
|
||||||
|
<option value="final_groups">{{ t('labels.liveViewFinalGroups') }}</option>
|
||||||
|
<option value="final_tie">{{ t('labels.liveViewFinalTie') }}</option>
|
||||||
|
<option value="podium">{{ t('labels.liveViewPodium') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-settings-row">
|
||||||
|
<label class="control-label">{{ t('labels.groupDisplayMode') }}</label>
|
||||||
|
<div class="inline-settings">
|
||||||
|
<select class="name-input" :value="liveSettings.groupDisplayMode" @change="emitPatch({ groupDisplayMode: $event.target.value })">
|
||||||
|
<option value="rotate">{{ t('actions.liveModeRotate') }}</option>
|
||||||
|
<option value="fixed">{{ t('actions.liveModeFixed') }}</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-if="liveSettings.groupDisplayMode === 'fixed'"
|
||||||
|
class="name-input"
|
||||||
|
:value="liveSettings.groupFixedCode || ''"
|
||||||
|
@change="emitPatch({ groupFixedCode: $event.target.value })"
|
||||||
|
>
|
||||||
|
<option value="">{{ t('labels.unassigned') }}</option>
|
||||||
|
<option v-for="group in assignableGroups" :key="'live-fixed-group-' + group" :value="group">{{ group }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-settings-row">
|
||||||
|
<label class="control-label">{{ t('labels.finalDisplayMode') }}</label>
|
||||||
|
<div class="inline-settings">
|
||||||
|
<select class="name-input" :value="liveSettings.finalDisplayMode" @change="emitPatch({ finalDisplayMode: $event.target.value })">
|
||||||
|
<option value="rotate">{{ t('actions.liveModeRotate') }}</option>
|
||||||
|
<option value="fixed">{{ t('actions.liveModeFixed') }}</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-if="liveSettings.finalDisplayMode === 'fixed'"
|
||||||
|
class="name-input"
|
||||||
|
:value="String(liveSettings.finalFixedGroup || 1)"
|
||||||
|
@change="emitPatch({ finalFixedGroup: Number($event.target.value) || 1 })"
|
||||||
|
>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-settings-row">
|
||||||
|
<label class="control-label">{{ t('labels.rotationIntervalSec') }}</label>
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
type="number"
|
||||||
|
min="3"
|
||||||
|
max="30"
|
||||||
|
:value="liveSettings.rotationIntervalSec || 5"
|
||||||
|
@change="emitPatch({ rotationIntervalSec: Number($event.target.value) || 5 })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-settings-row">
|
||||||
|
<label class="control-label">{{ t('labels.rotationPlayerCount') }}</label>
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
type="number"
|
||||||
|
min="3"
|
||||||
|
max="40"
|
||||||
|
:value="liveSettings.rotationPlayerCount || 12"
|
||||||
|
@change="emitPatch({ rotationPlayerCount: Number($event.target.value) || 12 })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
liveSettings: { type: Object, required: true },
|
||||||
|
assignableGroups: { type: Array, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update-live-setting'])
|
||||||
|
|
||||||
|
function emitPatch(patch) {
|
||||||
|
emit('update-live-setting', patch)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
206
frontend/src/components/admin/MultiRoundScoreEditor.vue
Normal file
206
frontend/src/components/admin/MultiRoundScoreEditor.vue
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="stage-filter-bar" v-if="showFilter">
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
:value="filterText"
|
||||||
|
:placeholder="t('actions.searchPlayer')"
|
||||||
|
@input="$emit('update:filter', $event.target.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap desktop-score-table">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th v-if="showGroup">{{ t('table.group') }}</th>
|
||||||
|
<th v-if="showRank">{{ t('table.rank') }}</th>
|
||||||
|
<th v-if="showSeed">{{ t('table.seed') }}</th>
|
||||||
|
<th v-for="round in roundStages" :key="'head-' + round.stage">{{ round.label }}</th>
|
||||||
|
<th>{{ t('table.total') }}</th>
|
||||||
|
<th>{{ t('table.verification') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, index) in filteredRows" :key="'multi-row-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)">
|
||||||
|
<td class="mono">{{ index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td v-if="showGroup" class="mono">{{ groupCell(row) }}</td>
|
||||||
|
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
|
||||||
|
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
|
||||||
|
<td v-for="round in roundStages" :key="'cell-' + round.stage + '-' + row.playerId" class="multi-round-cell">
|
||||||
|
<input
|
||||||
|
class="score-input score-input-compact"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
min="0"
|
||||||
|
max="9999"
|
||||||
|
:value="scoreInputValue(round.stage, row.playerId)"
|
||||||
|
@focus="onScoreFocus(round.stage, row.playerId)"
|
||||||
|
@input="onScoreInput(round.stage, row.playerId, $event)"
|
||||||
|
@blur="onScoreCommit(round.stage, row.playerId)"
|
||||||
|
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">{{ totalScore(row.playerId) }}</td>
|
||||||
|
<td class="verify-round-cell">
|
||||||
|
<div class="verify-round-list">
|
||||||
|
<div v-for="round in roundStages" :key="'verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
|
||||||
|
<span class="verify-round-label">{{ round.label }}</span>
|
||||||
|
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
|
||||||
|
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
v-if="hasScoreProof(round.stage, row.playerId)"
|
||||||
|
class="proof-thumb"
|
||||||
|
:src="scoreProofFor(round.stage, row.playerId)"
|
||||||
|
:alt="t('table.verification')"
|
||||||
|
@click="openProofPreview(round.stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="filteredRows.length === 0">
|
||||||
|
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-score-cards">
|
||||||
|
<article v-for="(row, index) in filteredRows" :key="'multi-mobile-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row)">
|
||||||
|
<div class="score-card-head">
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mono">#{{ index + 1 }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="score-card-meta">
|
||||||
|
<span v-if="showGroup">{{ t('table.group') }}: {{ groupCell(row) }}</span>
|
||||||
|
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
|
||||||
|
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-round-grid">
|
||||||
|
<label v-for="round in roundStages" :key="'mobile-round-' + round.stage + '-' + row.playerId" class="mobile-round-item">
|
||||||
|
<span class="score-label">{{ round.label }}</span>
|
||||||
|
<input
|
||||||
|
class="score-input"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
min="0"
|
||||||
|
max="9999"
|
||||||
|
:value="scoreInputValue(round.stage, row.playerId)"
|
||||||
|
@focus="onScoreFocus(round.stage, row.playerId)"
|
||||||
|
@input="onScoreInput(round.stage, row.playerId, $event)"
|
||||||
|
@blur="onScoreCommit(round.stage, row.playerId)"
|
||||||
|
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-total-line">
|
||||||
|
<span class="muted">{{ t('table.total') }}</span>
|
||||||
|
<strong class="mono">{{ totalScore(row.playerId) }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-verify-block">
|
||||||
|
<p class="mobile-verify-title">{{ t('table.verification') }}</p>
|
||||||
|
<div class="verify-round-list mobile">
|
||||||
|
<div v-for="round in roundStages" :key="'mobile-verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
|
||||||
|
<span class="verify-round-label">{{ round.label }}</span>
|
||||||
|
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
|
||||||
|
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
v-if="hasScoreProof(round.stage, row.playerId)"
|
||||||
|
class="proof-thumb"
|
||||||
|
:src="scoreProofFor(round.stage, row.playerId)"
|
||||||
|
:alt="t('table.verification')"
|
||||||
|
@click="openProofPreview(round.stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { scoreGroupStyle } from '../../utils/scoreGroupTheme'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
rows: { type: Array, required: true },
|
||||||
|
roundStages: { type: Array, required: true },
|
||||||
|
totalScore: { type: Function, required: true },
|
||||||
|
showFilter: { type: Boolean, default: true },
|
||||||
|
filterText: { type: String, default: '' },
|
||||||
|
showGroup: { type: Boolean, default: false },
|
||||||
|
showRank: { type: Boolean, default: false },
|
||||||
|
showSeed: { type: Boolean, default: false },
|
||||||
|
playerImage: { type: Function, required: true },
|
||||||
|
displayName: { type: Function, required: true },
|
||||||
|
secondaryName: { type: Function, required: true },
|
||||||
|
scoreInputValue: { type: Function, required: true },
|
||||||
|
onScoreFocus: { type: Function, required: true },
|
||||||
|
onScoreInput: { type: Function, required: true },
|
||||||
|
onScoreCommit: { type: Function, required: true },
|
||||||
|
hasScoreProof: { type: Function, required: true },
|
||||||
|
scoreProofFor: { type: Function, required: true },
|
||||||
|
openScoreProofUploader: { type: Function, required: true },
|
||||||
|
openProofPreview: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:filter'])
|
||||||
|
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const query = String(props.filterText || '').trim().toLowerCase()
|
||||||
|
if (!query) return props.rows
|
||||||
|
return props.rows.filter((row) => {
|
||||||
|
const ar = String(row.nameAr || '')
|
||||||
|
const en = String(row.nameEn || '').toLowerCase()
|
||||||
|
return ar.includes(query) || en.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const columnCount = computed(() => {
|
||||||
|
let count = 4 + props.roundStages.length
|
||||||
|
if (props.showGroup) count += 1
|
||||||
|
if (props.showRank) count += 1
|
||||||
|
if (props.showSeed) count += 1
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
function groupCell(row) {
|
||||||
|
if (row.finalGroup === 1 || row.finalGroup === 2) {
|
||||||
|
return row.finalGroup
|
||||||
|
}
|
||||||
|
return row.groupCode || props.t('labels.unassigned')
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreGroupStyleFor(row) {
|
||||||
|
return scoreGroupStyle(row)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
258
frontend/src/components/admin/PlayersManagementTab.vue
Normal file
258
frontend/src/components/admin/PlayersManagementTab.vue
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.playersTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.playersSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-config-card">
|
||||||
|
<div class="pm-config-top">
|
||||||
|
<div class="pm-config-input">
|
||||||
|
<input
|
||||||
|
:value="groupSetupInput"
|
||||||
|
class="name-input en"
|
||||||
|
dir="ltr"
|
||||||
|
:placeholder="t('sections.groupsConfigPlaceholder')"
|
||||||
|
@input="$emit('update:group-setup-input', $event.target.value)"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-outline" @click="$emit('save-group-setup')">{{ t('actions.updateGroups') }}</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" @click="$emit('auto-group-even')">{{ t('actions.randomEvenGroups') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-grid admin-summary-grid">
|
||||||
|
<article v-for="card in adminGroupCards" :key="'admin-group-' + card.key" class="summary-card" :class="'group-' + card.key">
|
||||||
|
<h3>{{ card.label }}</h3>
|
||||||
|
<p class="summary-value">{{ card.count }}</p>
|
||||||
|
<p class="summary-status ok">{{ t('labels.players') }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-composer-card">
|
||||||
|
<div class="pm-composer-grid">
|
||||||
|
<input
|
||||||
|
:value="newPlayer.nameAr"
|
||||||
|
class="name-input ar"
|
||||||
|
:placeholder="t('table.arabicName')"
|
||||||
|
@input="$emit('update:new-player', { key: 'nameAr', value: $event.target.value })"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
:value="newPlayer.nameEn"
|
||||||
|
class="name-input en"
|
||||||
|
dir="ltr"
|
||||||
|
:placeholder="t('table.englishName')"
|
||||||
|
@input="$emit('update:new-player', { key: 'nameEn', value: $event.target.value })"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
:value="newPlayer.groupCode"
|
||||||
|
class="name-input"
|
||||||
|
@change="$emit('update:new-player', { key: 'groupCode', value: $event.target.value })"
|
||||||
|
>
|
||||||
|
<option value="">{{ groupOptionLabel('') }}</option>
|
||||||
|
<option v-for="group in assignableGroups" :key="'new-group-' + group" :value="group">{{ groupOptionLabel(group) }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" @click="$emit('create-player')">{{ t('actions.addPlayer') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-composer-actions">
|
||||||
|
<button class="btn btn-outline" @click="$emit('convert-new-name', 'ar_to_en')">{{ t('actions.convertArToEn') }}</button>
|
||||||
|
<button class="btn btn-outline" @click="$emit('convert-new-name', 'en_to_ar')">{{ t('actions.convertEnToAr') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="players-tools players-tools-elevated">
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
:value="playerFilter"
|
||||||
|
:placeholder="t('actions.searchPlayer')"
|
||||||
|
@input="$emit('update:player-filter', $event.target.value)"
|
||||||
|
/>
|
||||||
|
<div class="players-sort-box">
|
||||||
|
<label class="control-label">{{ t('labels.sortBy') }}</label>
|
||||||
|
<select class="name-input" :value="playerSort" @change="$emit('update:player-sort', $event.target.value)">
|
||||||
|
<option value="id">{{ t('actions.sortById') }}</option>
|
||||||
|
<option value="nameAr">{{ t('actions.sortByArabic') }}</option>
|
||||||
|
<option value="nameEn">{{ t('actions.sortByEnglish') }}</option>
|
||||||
|
<option value="group">{{ t('actions.sortByGroup') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group-cards-grid">
|
||||||
|
<article v-for="group in groupedCards" :key="'group-card-' + group.code" class="group-player-card" :class="'group-' + group.key">
|
||||||
|
<header class="group-player-card-head">
|
||||||
|
<h3>{{ group.label }}</h3>
|
||||||
|
<span class="pm-count-badge mono">{{ group.players.length }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="group-player-list" v-if="group.players.length > 0">
|
||||||
|
<div class="group-player-row" v-for="player in group.players" :key="'card-player-' + player.id">
|
||||||
|
<div class="group-player-top">
|
||||||
|
<div class="competitor-cell">
|
||||||
|
<img :src="playerImage(player)" :alt="player.nameAr" class="competitor-image clickable" @click="$emit('open-image-uploader', player.id)" />
|
||||||
|
<div class="name-edit-grid vertical">
|
||||||
|
<input
|
||||||
|
class="name-input ar"
|
||||||
|
:value="player.nameAr"
|
||||||
|
:placeholder="t('table.arabicName')"
|
||||||
|
@blur="$emit('update-player-field', { player, field: 'nameAr', event: $event })"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="name-input en"
|
||||||
|
dir="ltr"
|
||||||
|
:value="player.nameEn"
|
||||||
|
:placeholder="t('table.englishName')"
|
||||||
|
@blur="$emit('update-player-field', { player, field: 'nameEn', event: $event })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-actions compact name-convert-row">
|
||||||
|
<button class="btn btn-outline btn-xs" @click="$emit('convert-row-name', { player, direction: 'ar_to_en' })">{{ t('actions.convertArToEn') }}</button>
|
||||||
|
<button class="btn btn-outline btn-xs" @click="$emit('convert-row-name', { player, direction: 'en_to_ar' })">{{ t('actions.convertEnToAr') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group-player-actions">
|
||||||
|
<select
|
||||||
|
class="name-input"
|
||||||
|
:value="normalizedGroupCode(player.groupCode)"
|
||||||
|
@change="$emit('update-player-group', { player, event: $event })"
|
||||||
|
>
|
||||||
|
<option value="">{{ groupOptionLabel('') }}</option>
|
||||||
|
<option v-for="item in assignableGroups" :key="'player-group-' + player.id + '-' + item" :value="item">
|
||||||
|
{{ groupOptionLabel(item) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-outline" @click="$emit('remove-player-image', player.id)">{{ t('actions.removeImage') }}</button>
|
||||||
|
<button class="btn btn-danger" @click="$emit('delete-player', player.id)">{{ t('actions.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
groupSetupInput: { type: String, default: '' },
|
||||||
|
adminGroupCards: { type: Array, required: true },
|
||||||
|
newPlayer: { type: Object, required: true },
|
||||||
|
assignableGroups: { type: Array, required: true },
|
||||||
|
playersSorted: { type: Array, required: true },
|
||||||
|
playerFilter: { type: String, default: '' },
|
||||||
|
playerSort: { type: String, default: 'id' },
|
||||||
|
playerImage: { type: Function, required: true },
|
||||||
|
groupOptionLabel: { type: Function, required: true },
|
||||||
|
normalizedGroupCode: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits([
|
||||||
|
'update:group-setup-input',
|
||||||
|
'save-group-setup',
|
||||||
|
'auto-group-even',
|
||||||
|
'update:new-player',
|
||||||
|
'create-player',
|
||||||
|
'convert-new-name',
|
||||||
|
'update:player-filter',
|
||||||
|
'update:player-sort',
|
||||||
|
'open-image-uploader',
|
||||||
|
'update-player-field',
|
||||||
|
'update-player-group',
|
||||||
|
'convert-row-name',
|
||||||
|
'remove-player-image',
|
||||||
|
'delete-player',
|
||||||
|
])
|
||||||
|
|
||||||
|
const filteredPlayers = computed(() => {
|
||||||
|
const query = String(props.playerFilter || '').trim().toLowerCase()
|
||||||
|
if (!query) return props.playersSorted
|
||||||
|
return props.playersSorted.filter((player) => {
|
||||||
|
const ar = String(player.nameAr || '')
|
||||||
|
const en = String(player.nameEn || '').toLowerCase()
|
||||||
|
return ar.includes(query) || en.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedPlayers = computed(() => {
|
||||||
|
const items = [...filteredPlayers.value]
|
||||||
|
items.sort((a, b) => comparePlayers(a, b, props.playerSort))
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedCards = computed(() => {
|
||||||
|
const groups = []
|
||||||
|
const groupMap = new Map()
|
||||||
|
|
||||||
|
for (const raw of props.assignableGroups || []) {
|
||||||
|
const code = props.normalizedGroupCode(raw)
|
||||||
|
if (!code || groupMap.has(code)) continue
|
||||||
|
groupMap.set(code, [])
|
||||||
|
groups.push(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const player of sortedPlayers.value) {
|
||||||
|
const code = props.normalizedGroupCode(player.groupCode)
|
||||||
|
if (!code) continue
|
||||||
|
if (!groupMap.has(code)) {
|
||||||
|
groupMap.set(code, [])
|
||||||
|
groups.push(code)
|
||||||
|
}
|
||||||
|
groupMap.get(code).push(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unassigned = sortedPlayers.value.filter((player) => !props.normalizedGroupCode(player.groupCode))
|
||||||
|
|
||||||
|
const cards = groups.map((code) => ({
|
||||||
|
code,
|
||||||
|
key: groupVisualKey(code),
|
||||||
|
label: `${props.t('labels.group')} ${code}`,
|
||||||
|
players: groupMap.get(code) || [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
cards.push({
|
||||||
|
code: '',
|
||||||
|
key: 'u',
|
||||||
|
label: props.t('labels.unassigned'),
|
||||||
|
players: unassigned,
|
||||||
|
})
|
||||||
|
|
||||||
|
return cards
|
||||||
|
})
|
||||||
|
|
||||||
|
function comparePlayers(a, b, sort) {
|
||||||
|
if (sort === 'nameAr') {
|
||||||
|
return String(a.nameAr || '').localeCompare(String(b.nameAr || ''), 'ar') || a.id - b.id
|
||||||
|
}
|
||||||
|
if (sort === 'nameEn') {
|
||||||
|
return String(a.nameEn || '').localeCompare(String(b.nameEn || ''), 'en') || a.id - b.id
|
||||||
|
}
|
||||||
|
if (sort === 'group') {
|
||||||
|
const ga = props.normalizedGroupCode(a.groupCode)
|
||||||
|
const gb = props.normalizedGroupCode(b.groupCode)
|
||||||
|
if (ga !== gb) {
|
||||||
|
if (!ga) return 1
|
||||||
|
if (!gb) return -1
|
||||||
|
return ga.localeCompare(gb)
|
||||||
|
}
|
||||||
|
return a.id - b.id
|
||||||
|
}
|
||||||
|
return a.id - b.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupVisualKey(code) {
|
||||||
|
const normalized = props.normalizedGroupCode(code)
|
||||||
|
if (normalized.startsWith('A')) return 'a'
|
||||||
|
if (normalized.startsWith('B')) return 'b'
|
||||||
|
if (normalized.startsWith('C')) return 'c'
|
||||||
|
if (normalized.startsWith('D')) return 'd'
|
||||||
|
return 'u'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
211
frontend/src/components/admin/ScoreStageEditor.vue
Normal file
211
frontend/src/components/admin/ScoreStageEditor.vue
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="stage-filter-bar" v-if="showFilter">
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
:value="filterText"
|
||||||
|
:placeholder="t('actions.searchPlayer')"
|
||||||
|
@input="$emit('update:filter', $event.target.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap desktop-score-table">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th v-if="showGroup">{{ t('table.group') }}</th>
|
||||||
|
<th v-if="showScoreBeforeInput">{{ t('table.score') }}</th>
|
||||||
|
<th>{{ inputLabel }}</th>
|
||||||
|
<th>{{ t('table.verification') }}</th>
|
||||||
|
<th v-if="showRank">{{ t('table.rank') }}</th>
|
||||||
|
<th v-if="showSeed">{{ t('table.seed') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, index) in filteredRows" :key="'desk-' + stage + '-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)">
|
||||||
|
<td class="mono">{{ index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td v-if="showGroup" class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
|
||||||
|
<td v-if="showScoreBeforeInput" class="mono strong">{{ row.score }}</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
class="score-input"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
min="0"
|
||||||
|
max="9999"
|
||||||
|
:value="scoreInputValue(stage, row.playerId)"
|
||||||
|
@focus="onScoreFocus(stage, row.playerId)"
|
||||||
|
@input="onScoreInput(stage, row.playerId, $event)"
|
||||||
|
@blur="onScoreCommit(stage, row.playerId)"
|
||||||
|
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="proof-actions">
|
||||||
|
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
|
||||||
|
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||||
|
</button>
|
||||||
|
<!-- <button
|
||||||
|
v-if="hasScoreProof(stage, row.playerId)"
|
||||||
|
class="btn btn-ai btn-xs"
|
||||||
|
@click="requestScoreAdvice(stage, row.playerId)"
|
||||||
|
>
|
||||||
|
{{ t('actions.aiAdvisor') }}
|
||||||
|
</button> -->
|
||||||
|
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
|
||||||
|
{{ t('actions.removeProof') }}
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
v-if="hasScoreProof(stage, row.playerId)"
|
||||||
|
class="proof-thumb"
|
||||||
|
:src="scoreProofFor(stage, row.playerId)"
|
||||||
|
:alt="t('table.verification')"
|
||||||
|
@click="openProofPreview(stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
|
||||||
|
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="filteredRows.length === 0">
|
||||||
|
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-score-cards">
|
||||||
|
<article v-for="(row, index) in filteredRows" :key="'mob-' + stage + '-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row)">
|
||||||
|
<div class="score-card-head">
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mono">#{{ index + 1 }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="score-card-meta">
|
||||||
|
<span v-if="showGroup">{{ t('table.group') }}: {{ row.groupCode || t('labels.unassigned') }}</span>
|
||||||
|
<span v-if="showScoreBeforeInput">{{ t('table.score') }}: {{ row.score }}</span>
|
||||||
|
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
|
||||||
|
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="score-label">{{ inputLabel }}</label>
|
||||||
|
<input
|
||||||
|
class="score-input"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
min="0"
|
||||||
|
max="9999"
|
||||||
|
:value="scoreInputValue(stage, row.playerId)"
|
||||||
|
@focus="onScoreFocus(stage, row.playerId)"
|
||||||
|
@input="onScoreInput(stage, row.playerId, $event)"
|
||||||
|
@blur="onScoreCommit(stage, row.playerId)"
|
||||||
|
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="proof-actions mobile-proof-actions">
|
||||||
|
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
|
||||||
|
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||||
|
</button>
|
||||||
|
<!-- <button
|
||||||
|
v-if="hasScoreProof(stage, row.playerId)"
|
||||||
|
class="btn btn-ai btn-xs"
|
||||||
|
@click="requestScoreAdvice(stage, row.playerId)"
|
||||||
|
>
|
||||||
|
{{ t('actions.aiAdvisor') }}
|
||||||
|
</button> -->
|
||||||
|
|
||||||
|
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
|
||||||
|
{{ t('actions.removeProof') }}
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
v-if="hasScoreProof(stage, row.playerId)"
|
||||||
|
class="proof-thumb"
|
||||||
|
:src="scoreProofFor(stage, row.playerId)"
|
||||||
|
:alt="t('table.verification')"
|
||||||
|
@click="openProofPreview(stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { scoreGroupStyle, scoreValueStyle } from '../../utils/scoreGroupTheme'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
stage: { type: String, required: true },
|
||||||
|
rows: { type: Array, required: true },
|
||||||
|
showFilter: { type: Boolean, default: true },
|
||||||
|
filterText: { type: String, default: '' },
|
||||||
|
showGroup: { type: Boolean, default: false },
|
||||||
|
showRank: { type: Boolean, default: false },
|
||||||
|
showSeed: { type: Boolean, default: false },
|
||||||
|
colorMode: { type: String, default: 'group' },
|
||||||
|
showScoreBeforeInput: { type: Boolean, default: false },
|
||||||
|
inputLabel: { type: String, required: true },
|
||||||
|
playerImage: { type: Function, required: true },
|
||||||
|
displayName: { type: Function, required: true },
|
||||||
|
secondaryName: { type: Function, required: true },
|
||||||
|
scoreInputValue: { type: Function, required: true },
|
||||||
|
onScoreFocus: { type: Function, required: true },
|
||||||
|
onScoreInput: { type: Function, required: true },
|
||||||
|
onScoreCommit: { type: Function, required: true },
|
||||||
|
hasScoreProof: { type: Function, required: true },
|
||||||
|
scoreProofFor: { type: Function, required: true },
|
||||||
|
openScoreProofUploader: { type: Function, required: true },
|
||||||
|
removeScoreProof: { type: Function, required: true },
|
||||||
|
openProofPreview: { type: Function, required: true },
|
||||||
|
requestScoreAdvice: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:filter'])
|
||||||
|
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const query = String(props.filterText || '').trim().toLowerCase()
|
||||||
|
if (!query) return props.rows
|
||||||
|
return props.rows.filter((row) => {
|
||||||
|
const ar = String(row.nameAr || '')
|
||||||
|
const en = String(row.nameEn || '').toLowerCase()
|
||||||
|
return ar.includes(query) || en.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const columnCount = computed(() => {
|
||||||
|
let count = 4
|
||||||
|
if (props.showGroup) count += 1
|
||||||
|
if (props.showScoreBeforeInput) count += 1
|
||||||
|
if (props.showRank) count += 1
|
||||||
|
if (props.showSeed) count += 1
|
||||||
|
return count
|
||||||
|
})
|
||||||
|
|
||||||
|
function scoreGroupStyleFor(row) {
|
||||||
|
if (props.colorMode === 'score') {
|
||||||
|
return scoreValueStyle(row?.score)
|
||||||
|
}
|
||||||
|
return scoreGroupStyle(row)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
397
frontend/src/constants/i18n.js
Normal file
397
frontend/src/constants/i18n.js
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
export const I18N = {
|
||||||
|
ar: {
|
||||||
|
titleFallback: 'بطولة دويتوايلر للرماية',
|
||||||
|
subtitle: 'فئة المسدس · مسافات: 15م ← 20م ← 25م',
|
||||||
|
viewMode: 'وضع العرض',
|
||||||
|
adminMode: 'لوحة الإدارة',
|
||||||
|
liveMode: 'الوضع الحي',
|
||||||
|
adminLogin: 'تسجيل دخول الإدارة',
|
||||||
|
adminLoginDesc: 'أدخل بيانات الإدارة للتحكم الكامل في البطولة.',
|
||||||
|
adminPanel: 'لوحة التحكم الإدارية',
|
||||||
|
adminPanelDesc: 'إدارة اللاعبين، التوزيع، وإدخال النتائج لكل المراحل.',
|
||||||
|
defaultCredentials: 'بيانات الإدارة الافتراضية: datwyler / datwyler',
|
||||||
|
labels: {
|
||||||
|
liveTracker: 'LIVE TRACKER',
|
||||||
|
mode: 'الوضع',
|
||||||
|
language: 'اللغة',
|
||||||
|
lastSync: 'آخر مزامنة',
|
||||||
|
loading: 'جاري تحميل بيانات البطولة...',
|
||||||
|
players: 'لاعب',
|
||||||
|
group: 'المجموعة',
|
||||||
|
unassigned: 'غير معين',
|
||||||
|
emptyLive: 'لا يوجد لاعبين في هذه المجموعة',
|
||||||
|
allPlayers: 'جميع اللاعبين',
|
||||||
|
top12: 'أفضل 12 متأهل',
|
||||||
|
finalist: 'متأهل',
|
||||||
|
notFinalist: 'غير متأهل',
|
||||||
|
noPlayers: 'لا يوجد لاعبين بعد',
|
||||||
|
noFinalists: 'لا يوجد متأهلون بعد',
|
||||||
|
finalGroup1: 'المجموعة النهائية 1 (المراكز 1-6)',
|
||||||
|
finalGroup2: 'المجموعة النهائية 2 (المراكز 7-12)',
|
||||||
|
tieSlots: 'عدد المقاعد المتاحة من كسر التعادل',
|
||||||
|
waiting: 'بانتظار النتيجة',
|
||||||
|
viewProofInView: 'إظهار صور إثبات النتيجة في وضع العرض',
|
||||||
|
liveModeVisibility: 'إعدادات شاشة الوضع الحي',
|
||||||
|
activeLiveView: 'العرض النشط في الوضع الحي',
|
||||||
|
rotationIntervalSec: 'مدة التدوير (ثانية)',
|
||||||
|
rotationPlayerCount: 'عدد اللاعبين لكل صفحة تدوير',
|
||||||
|
liveViewGroup: 'شاشة المجموعات الحية',
|
||||||
|
liveViewPrelimTie: 'كسر تعادل التمهيدي',
|
||||||
|
liveViewPrelimOverall: 'الترتيب العام للتمهيدي',
|
||||||
|
liveViewFinalGroups: 'شاشة مجموعات النهائي',
|
||||||
|
liveViewFinalTie: 'كسر تعادل النهائي',
|
||||||
|
liveViewPodium: 'منصة التتويج',
|
||||||
|
showGroupLive: 'إظهار شاشة المجموعات الحية',
|
||||||
|
showPrelimTie: 'إظهار كسر تعادل التمهيدي',
|
||||||
|
showPrelimOverall: 'إظهار ترتيب التمهيدي العام',
|
||||||
|
showFinalGroups: 'إظهار مجموعات النهائي',
|
||||||
|
showFinalTie: 'إظهار كسر تعادل النهائي',
|
||||||
|
showPodium: 'إظهار منصة التتويج',
|
||||||
|
groupDisplayMode: 'عرض المجموعات',
|
||||||
|
finalDisplayMode: 'عرض مجموعات النهائي',
|
||||||
|
fixedGroup: 'مجموعة ثابتة',
|
||||||
|
fixedFinalGroup: 'المجموعة النهائية الثابتة',
|
||||||
|
sortBy: 'الترتيب',
|
||||||
|
zoom: 'التكبير',
|
||||||
|
currentScore: 'النتيجة الحالية',
|
||||||
|
aiSuggestedScore: 'النتيجة المقترحة',
|
||||||
|
confidence: 'مستوى الثقة',
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
groupsTitle: 'عرض اللاعبين والمجموعات',
|
||||||
|
groupsSubtitle: 'شاشة نظيفة للمتابعة المباشرة حسب المجموعة.',
|
||||||
|
liveTitle: 'العرض الحي للمجموعة',
|
||||||
|
liveSubtitle: 'تدوير تلقائي بين المجموعات المسجلة كل 5 ثوان.',
|
||||||
|
overallTitle: 'الترتيب العام للمرحلة التمهيدية',
|
||||||
|
overallSubtitle: 'يتم تمييز أفضل 12 متأهل للنهائي.',
|
||||||
|
finalTitle: 'المرحلة النهائية',
|
||||||
|
finalSubtitle: 'تقسيم المتأهلين إلى مجموعتين حسب الترتيب.',
|
||||||
|
finalRanking: 'الترتيب النهائي',
|
||||||
|
podiumTitle: 'منصة التتويج',
|
||||||
|
podiumSubtitle: 'المراكز الثلاثة الأولى بعد فك أي تعادل.',
|
||||||
|
playersTitle: 'إدارة اللاعبين',
|
||||||
|
playersSubtitle: 'إضافة/تعديل/حذف لاعب، وتعديل الاسم والصورة والمجموعة.',
|
||||||
|
groupsConfigPlaceholder: 'المجموعات الأساسية (مثال: A,B,C,D)',
|
||||||
|
preliminaryAdminTitle: 'إدخال نتائج المرحلة التمهيدية',
|
||||||
|
preliminaryAdminSubtitle: 'إدخال يدوي للنتيجة التمهيدية لكل لاعب.',
|
||||||
|
prelimTieTitle: 'إدخال كسر تعادل التأهل (أفضل 12)',
|
||||||
|
prelimTieSubtitle: 'يظهر فقط اللاعبين المتعادلين على حد التأهل.',
|
||||||
|
finalAdminTitle: 'إدخال نتائج المرحلة النهائية',
|
||||||
|
finalAdminSubtitle: 'إدخال نتائج النهائي للمجموعتين.',
|
||||||
|
finalTieTitle: 'إدخال كسر تعادل منصة التتويج',
|
||||||
|
finalTieSubtitle: 'يظهر فقط عند تعادل المراكز 1-3.',
|
||||||
|
profileCropTitle: 'تعديل صورة اللاعب',
|
||||||
|
profileCropSubtitle: 'حرّك وكبّر الصورة لتناسب الإطار الدائري قبل الحفظ.',
|
||||||
|
aiAdvisorTitle: 'مساعد الذكاء الاصطناعي للنتيجة',
|
||||||
|
liveModeTitle: 'شاشة البطولة المباشرة',
|
||||||
|
liveModeSubtitle: 'عرض مخصص للشاشات أثناء الفعالية مع تحديث لحظي.',
|
||||||
|
prelimRoundsTitle: 'الجولات التمهيدية',
|
||||||
|
finalRoundsTitle: 'جولات النهائي',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
competitor: 'اللاعب',
|
||||||
|
group: 'المجموعة',
|
||||||
|
rank: 'الترتيب',
|
||||||
|
score: 'النتيجة',
|
||||||
|
total: 'المجموع',
|
||||||
|
round1: 'جولة 1',
|
||||||
|
round2: 'جولة 2',
|
||||||
|
round3: 'جولة 3',
|
||||||
|
tieScore: 'نتيجة كسر التعادل',
|
||||||
|
verification: 'التحقق',
|
||||||
|
status: 'الحالة',
|
||||||
|
seed: 'التصنيف',
|
||||||
|
medal: 'الميدالية',
|
||||||
|
actions: 'الإجراءات',
|
||||||
|
arabicName: 'الاسم بالعربية',
|
||||||
|
englishName: 'الاسم بالإنجليزية',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
refresh: 'تحديث',
|
||||||
|
login: 'دخول',
|
||||||
|
logout: 'تسجيل خروج',
|
||||||
|
updateGroups: 'تحديث المجموعات',
|
||||||
|
liveRotate: 'دوران تلقائي',
|
||||||
|
liveFixed: 'مجموعة محددة',
|
||||||
|
uploadProof: 'رفع إثبات',
|
||||||
|
replaceProof: 'استبدال الإثبات',
|
||||||
|
removeProof: 'حذف الإثبات',
|
||||||
|
addPlayer: 'إضافة لاعب',
|
||||||
|
removeImage: 'حذف الصورة',
|
||||||
|
delete: 'حذف',
|
||||||
|
resetScores: 'تصفير نتائج المرحلة',
|
||||||
|
resetOnlyScores: 'تصفير النتائج فقط',
|
||||||
|
resetScoresAndProofs: 'تصفير النتائج وحذف الإثبات',
|
||||||
|
randomEvenGroups: 'توزيع عشوائي متوازن',
|
||||||
|
searchPlayer: 'بحث بالاسم العربي أو الإنجليزي',
|
||||||
|
cancel: 'إلغاء',
|
||||||
|
convertArToEn: 'تحويل عربي → إنجليزي',
|
||||||
|
convertEnToAr: 'تحويل إنجليزي → عربي',
|
||||||
|
sortById: 'حسب الرقم',
|
||||||
|
sortByArabic: 'حسب الاسم العربي',
|
||||||
|
sortByEnglish: 'حسب الاسم الإنجليزي',
|
||||||
|
sortByGroup: 'حسب المجموعة',
|
||||||
|
aiAdvisor: 'اقتراح AI',
|
||||||
|
quickApplyAi: 'تطبيق سريع AI',
|
||||||
|
reAnalyze: 'إعادة التحليل',
|
||||||
|
applySuggestedScore: 'اعتماد النتيجة المقترحة',
|
||||||
|
applyCrop: 'تطبيق القص',
|
||||||
|
resetPosition: 'إعادة الضبط',
|
||||||
|
liveModeRotate: 'تدوير تلقائي',
|
||||||
|
liveModeFixed: 'ثابت',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
username: 'اسم المستخدم',
|
||||||
|
password: 'كلمة المرور',
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
groups: 'المجموعات',
|
||||||
|
live: 'عرض حي',
|
||||||
|
overall: 'الترتيب العام',
|
||||||
|
final: 'النهائي',
|
||||||
|
podium: 'التتويج',
|
||||||
|
players: 'اللاعبون',
|
||||||
|
preliminary: 'التمهيدي',
|
||||||
|
prelimTie: 'كسر تعادل التأهل',
|
||||||
|
finalTie: 'كسر تعادل التتويج',
|
||||||
|
liveModeAdmin: 'إعدادات الوضع الحي',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
saved: 'تم الحفظ بنجاح.',
|
||||||
|
mustProvideNames: 'يرجى إدخال الاسم بالعربية والإنجليزية.',
|
||||||
|
noPrelimTie: 'لا يوجد تعادل على حد التأهل.',
|
||||||
|
noFinalTie: 'لا يوجد تعادل في المراكز 1-3.',
|
||||||
|
prelimTieUnresolved: 'تعادل التأهل غير محسوم. أدخل نتائج كسر التعادل لتحديد أفضل 12.',
|
||||||
|
finalTieUnresolved: 'تعادل منصة التتويج غير محسوم. أكمل إدخال كسر التعادل.',
|
||||||
|
confirmDelete: 'هل تريد حذف اللاعب؟',
|
||||||
|
confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟',
|
||||||
|
resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟',
|
||||||
|
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 9999.',
|
||||||
|
unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.',
|
||||||
|
errorPrefix: 'حدث خطأ',
|
||||||
|
noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.',
|
||||||
|
noNameToConvert: 'لا يوجد اسم للتحويل.',
|
||||||
|
aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...',
|
||||||
|
noProofForAi: 'لا توجد صورة إثبات لتحليلها.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
titleFallback: 'Datwyler Shooting Event',
|
||||||
|
subtitle: 'Pistol class · Distances: 15m ← 20m ← 25m',
|
||||||
|
viewMode: 'View Mode',
|
||||||
|
adminMode: 'Admin Panel',
|
||||||
|
liveMode: 'Live Mode',
|
||||||
|
adminLogin: 'Admin Login',
|
||||||
|
adminLoginDesc: 'Enter admin credentials for full tournament control.',
|
||||||
|
adminPanel: 'Admin Control Panel',
|
||||||
|
adminPanelDesc: 'Manage players, assignments, and scoring for all stages.',
|
||||||
|
defaultCredentials: 'Default admin credentials: datwyler / datwyler',
|
||||||
|
labels: {
|
||||||
|
liveTracker: 'LIVE TRACKER',
|
||||||
|
mode: 'Mode',
|
||||||
|
language: 'Language',
|
||||||
|
lastSync: 'Last sync',
|
||||||
|
loading: 'Loading tournament data...',
|
||||||
|
players: 'Players',
|
||||||
|
group: 'Group',
|
||||||
|
unassigned: 'Unassigned',
|
||||||
|
emptyLive: 'No players in this group',
|
||||||
|
allPlayers: 'All players',
|
||||||
|
top12: 'Top 12 finalists',
|
||||||
|
finalist: 'Finalist',
|
||||||
|
notFinalist: 'Not finalist',
|
||||||
|
noPlayers: 'No players yet',
|
||||||
|
noFinalists: 'No finalists yet',
|
||||||
|
finalGroup1: 'Final Group 1 (Seeds 1-6)',
|
||||||
|
finalGroup2: 'Final Group 2 (Seeds 7-12)',
|
||||||
|
tieSlots: 'Tie-break slots',
|
||||||
|
waiting: 'Waiting',
|
||||||
|
viewProofInView: 'Allow proof images in view mode',
|
||||||
|
liveModeVisibility: 'Live mode screen controls',
|
||||||
|
activeLiveView: 'Active live view',
|
||||||
|
rotationIntervalSec: 'Rotation interval (sec)',
|
||||||
|
rotationPlayerCount: 'Players per rotation page',
|
||||||
|
liveViewGroup: 'Live group board',
|
||||||
|
liveViewPrelimTie: 'Preliminary tie-break',
|
||||||
|
liveViewPrelimOverall: 'Preliminary overall',
|
||||||
|
liveViewFinalGroups: 'Final groups board',
|
||||||
|
liveViewFinalTie: 'Final tie-break',
|
||||||
|
liveViewPodium: 'Podium',
|
||||||
|
showGroupLive: 'Show live group board',
|
||||||
|
showPrelimTie: 'Show preliminary tie-break',
|
||||||
|
showPrelimOverall: 'Show preliminary overall ranking',
|
||||||
|
showFinalGroups: 'Show final groups board',
|
||||||
|
showFinalTie: 'Show final tie-break',
|
||||||
|
showPodium: 'Show podium',
|
||||||
|
groupDisplayMode: 'Group board mode',
|
||||||
|
finalDisplayMode: 'Final board mode',
|
||||||
|
fixedGroup: 'Fixed group',
|
||||||
|
fixedFinalGroup: 'Fixed final group',
|
||||||
|
sortBy: 'Sort by',
|
||||||
|
zoom: 'Zoom',
|
||||||
|
currentScore: 'Current score',
|
||||||
|
aiSuggestedScore: 'AI suggested score',
|
||||||
|
confidence: 'Confidence',
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
groupsTitle: 'Players & Groups Overview',
|
||||||
|
groupsSubtitle: 'Clean view screen for live grouping.',
|
||||||
|
liveTitle: 'Live Group Screen',
|
||||||
|
liveSubtitle: 'Automatic rotation through registered groups every 5 seconds.',
|
||||||
|
overallTitle: 'Preliminary Overall Ranking',
|
||||||
|
overallSubtitle: 'Top 12 finalists are highlighted.',
|
||||||
|
finalTitle: 'Final Stage',
|
||||||
|
finalSubtitle: 'Finalists are split into two groups by rank.',
|
||||||
|
finalRanking: 'Final Ranking',
|
||||||
|
podiumTitle: 'Podium',
|
||||||
|
podiumSubtitle: 'Top 3 after tie-break resolution.',
|
||||||
|
playersTitle: 'Players Management',
|
||||||
|
playersSubtitle: 'Add/update/remove players, names, images, and group assignment.',
|
||||||
|
groupsConfigPlaceholder: 'Primary groups (example: A,B,C,D)',
|
||||||
|
preliminaryAdminTitle: 'Preliminary Scoring',
|
||||||
|
preliminaryAdminSubtitle: 'Manually input each player preliminary score.',
|
||||||
|
prelimTieTitle: 'Qualification Tie-Break Scoring (Top 12)',
|
||||||
|
prelimTieSubtitle: 'Only players tied at qualification cutoff are shown.',
|
||||||
|
finalAdminTitle: 'Final Stage Scoring',
|
||||||
|
finalAdminSubtitle: 'Input final scores for both final groups.',
|
||||||
|
finalTieTitle: 'Podium Tie-Break Scoring',
|
||||||
|
finalTieSubtitle: 'Shown only when places 1-3 are tied.',
|
||||||
|
profileCropTitle: 'Adjust Player Photo',
|
||||||
|
profileCropSubtitle: 'Move and zoom to fit the circular avatar before saving.',
|
||||||
|
aiAdvisorTitle: 'AI Score Advisor',
|
||||||
|
liveModeTitle: 'Event Live Screen',
|
||||||
|
liveModeSubtitle: 'Monitor-ready display with real-time updates.',
|
||||||
|
prelimRoundsTitle: 'Preliminary Rounds',
|
||||||
|
finalRoundsTitle: 'Final Rounds',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
competitor: 'Player',
|
||||||
|
group: 'Group',
|
||||||
|
rank: 'Rank',
|
||||||
|
score: 'Score',
|
||||||
|
total: 'Total',
|
||||||
|
round1: 'Round 1',
|
||||||
|
round2: 'Round 2',
|
||||||
|
round3: 'Round 3',
|
||||||
|
tieScore: 'Tie-Break Score',
|
||||||
|
verification: 'Verification',
|
||||||
|
status: 'Status',
|
||||||
|
seed: 'Seed',
|
||||||
|
medal: 'Medal',
|
||||||
|
actions: 'Actions',
|
||||||
|
arabicName: 'Arabic Name',
|
||||||
|
englishName: 'English Name',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
refresh: 'Refresh',
|
||||||
|
login: 'Login',
|
||||||
|
logout: 'Logout',
|
||||||
|
updateGroups: 'Update Groups',
|
||||||
|
liveRotate: 'Auto Rotation',
|
||||||
|
liveFixed: 'Fixed Group',
|
||||||
|
uploadProof: 'Upload Proof',
|
||||||
|
replaceProof: 'Replace Proof',
|
||||||
|
removeProof: 'Remove Proof',
|
||||||
|
addPlayer: 'Add Player',
|
||||||
|
removeImage: 'Remove Image',
|
||||||
|
delete: 'Delete',
|
||||||
|
resetScores: 'Reset Stage Scores',
|
||||||
|
resetOnlyScores: 'Reset scores only',
|
||||||
|
resetScoresAndProofs: 'Reset scores and proofs',
|
||||||
|
randomEvenGroups: 'Random even grouping',
|
||||||
|
searchPlayer: 'Search by Arabic or English name',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
convertArToEn: 'Convert Arabic → English',
|
||||||
|
convertEnToAr: 'Convert English → Arabic',
|
||||||
|
sortById: 'By ID',
|
||||||
|
sortByArabic: 'By Arabic name',
|
||||||
|
sortByEnglish: 'By English name',
|
||||||
|
sortByGroup: 'By group',
|
||||||
|
aiAdvisor: 'AI Advice',
|
||||||
|
quickApplyAi: 'Quick Apply AI',
|
||||||
|
reAnalyze: 'Re-analyze',
|
||||||
|
applySuggestedScore: 'Apply suggested score',
|
||||||
|
applyCrop: 'Apply crop',
|
||||||
|
resetPosition: 'Reset position',
|
||||||
|
liveModeRotate: 'Auto rotate',
|
||||||
|
liveModeFixed: 'Fixed',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
username: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
groups: 'Groups',
|
||||||
|
live: 'Live',
|
||||||
|
overall: 'Overall',
|
||||||
|
final: 'Final',
|
||||||
|
podium: 'Podium',
|
||||||
|
players: 'Players',
|
||||||
|
preliminary: 'Preliminary',
|
||||||
|
prelimTie: 'Prelim Tie-Break',
|
||||||
|
finalTie: 'Final Tie-Break',
|
||||||
|
liveModeAdmin: 'Live Mode Settings',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
saved: 'Saved successfully.',
|
||||||
|
mustProvideNames: 'Arabic and English names are required.',
|
||||||
|
noPrelimTie: 'No tie at qualification cutoff.',
|
||||||
|
noFinalTie: 'No tie in places 1-3.',
|
||||||
|
prelimTieUnresolved: 'Qualification tie is unresolved. Enter tie-break scores to finalize top 12.',
|
||||||
|
finalTieUnresolved: 'Podium tie is unresolved. Complete tie-break scoring.',
|
||||||
|
confirmDelete: 'Delete this player?',
|
||||||
|
confirmReset: 'Reset this stage scores?',
|
||||||
|
resetProofPrompt: 'Also remove all proof images for this stage?',
|
||||||
|
invalidScore: 'Score must be between 0 and 9999.',
|
||||||
|
unauthorized: 'Admin session expired. Please login again.',
|
||||||
|
errorPrefix: 'Error',
|
||||||
|
noGroupsConfigured: 'No primary groups configured.',
|
||||||
|
noNameToConvert: 'No name available to convert.',
|
||||||
|
aiAnalyzing: 'Analyzing image with AI...',
|
||||||
|
noProofForAi: 'No proof image available to analyze.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialState() {
|
||||||
|
return {
|
||||||
|
competition: { titleAr: '', titleEn: '' },
|
||||||
|
players: [],
|
||||||
|
scores: {
|
||||||
|
prelim_r1: {},
|
||||||
|
prelim_r2: {},
|
||||||
|
prelim_r3: {},
|
||||||
|
prelim_tiebreak: {},
|
||||||
|
final_r1: {},
|
||||||
|
final_r2: {},
|
||||||
|
final_tiebreak: {},
|
||||||
|
},
|
||||||
|
scoreProofs: {},
|
||||||
|
settings: {
|
||||||
|
viewProofInView: false,
|
||||||
|
liveMode: {
|
||||||
|
activeView: 'group_live',
|
||||||
|
showGroupLive: true,
|
||||||
|
groupDisplayMode: 'rotate',
|
||||||
|
groupFixedCode: '',
|
||||||
|
showPrelimTie: true,
|
||||||
|
showPrelimOverall: true,
|
||||||
|
showFinalGroups: true,
|
||||||
|
finalDisplayMode: 'rotate',
|
||||||
|
finalFixedGroup: 1,
|
||||||
|
showFinalTie: true,
|
||||||
|
showPodium: true,
|
||||||
|
rotationIntervalSec: 5,
|
||||||
|
rotationPlayerCount: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
derived: {
|
||||||
|
preliminaryRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
|
||||||
|
finalists: [],
|
||||||
|
finalGroups: { group1: [], group2: [] },
|
||||||
|
finalRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
|
||||||
|
podium: [],
|
||||||
|
},
|
||||||
|
serverTime: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/src/main.js
Normal file
5
frontend/src/main.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
2281
frontend/src/style.css
Normal file
2281
frontend/src/style.css
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/src/utils/groups.js
Normal file
28
frontend/src/utils/groups.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export const DEFAULT_PRIMARY_GROUPS = ['A', 'B', 'C', 'D']
|
||||||
|
|
||||||
|
export function normalizedGroupCode(value) {
|
||||||
|
return String(value || '').trim().toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGroupList(raw) {
|
||||||
|
const items = String(raw || '')
|
||||||
|
.split(',')
|
||||||
|
.map((item) => normalizedGroupCode(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
return [...new Set(items)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPrimaryGroups(storage) {
|
||||||
|
const stored = storage.getItem('shooting_group_list')
|
||||||
|
const parsed = parseGroupList(stored)
|
||||||
|
return parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupKey(code) {
|
||||||
|
const normalized = normalizedGroupCode(code)
|
||||||
|
if (normalized.startsWith('A')) return 'a'
|
||||||
|
if (normalized.startsWith('B')) return 'b'
|
||||||
|
if (normalized.startsWith('C')) return 'c'
|
||||||
|
if (normalized.startsWith('D')) return 'd'
|
||||||
|
return 'u'
|
||||||
|
}
|
||||||
220
frontend/src/utils/nameTransliteration.js
Normal file
220
frontend/src/utils/nameTransliteration.js
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
const ARABIC_DIACRITICS = /[\u064B-\u065F\u0670\u0640]/g
|
||||||
|
|
||||||
|
const AR_TO_EN_WORD = {
|
||||||
|
محمد: 'Mohammad',
|
||||||
|
احمد: 'Ahmad',
|
||||||
|
محمود: 'Mahmoud',
|
||||||
|
عبدالرحمن: 'Abdulrahman',
|
||||||
|
عبدالله: 'Abdullah',
|
||||||
|
عبدالله: 'Abdullah',
|
||||||
|
عبدالاله: 'Abdulilah',
|
||||||
|
عمر: 'Omar',
|
||||||
|
علي: 'Ali',
|
||||||
|
خالد: 'Khaled',
|
||||||
|
خليل: 'Khalil',
|
||||||
|
يزن: 'Yazan',
|
||||||
|
يزيد: 'Yazeed',
|
||||||
|
معاذ: 'Moaz',
|
||||||
|
طارق: 'Tareq',
|
||||||
|
زيد: 'Zaid',
|
||||||
|
سامر: 'Samer',
|
||||||
|
سيف: 'Saif',
|
||||||
|
حسام: 'Hossam',
|
||||||
|
باسم: 'Bassem',
|
||||||
|
امجد: 'Amjad',
|
||||||
|
مأمون: 'Maamoun',
|
||||||
|
اياد: 'Eyad',
|
||||||
|
إياد: 'Eyad',
|
||||||
|
حمزة: 'Hamza',
|
||||||
|
حمزه: 'Hamza',
|
||||||
|
هيثم: 'Haitham',
|
||||||
|
وائل: 'Wael',
|
||||||
|
رائد: 'Raed',
|
||||||
|
فهد: 'Fahad',
|
||||||
|
فارس: 'Fares',
|
||||||
|
ناصر: 'Nasser',
|
||||||
|
جميل: 'Jameel',
|
||||||
|
}
|
||||||
|
|
||||||
|
const EN_TO_AR_WORD = {
|
||||||
|
mohammad: 'محمد',
|
||||||
|
muhammad: 'محمد',
|
||||||
|
ahmad: 'أحمد',
|
||||||
|
ahmed: 'أحمد',
|
||||||
|
mahmoud: 'محمود',
|
||||||
|
abdullah: 'عبدالله',
|
||||||
|
abdallah: 'عبدالله',
|
||||||
|
abdulrahman: 'عبدالرحمن',
|
||||||
|
omar: 'عمر',
|
||||||
|
ali: 'علي',
|
||||||
|
khaled: 'خالد',
|
||||||
|
khalil: 'خليل',
|
||||||
|
yazan: 'يزن',
|
||||||
|
yazeed: 'يزيد',
|
||||||
|
moaz: 'معاذ',
|
||||||
|
tareq: 'طارق',
|
||||||
|
tariq: 'طارق',
|
||||||
|
zaid: 'زيد',
|
||||||
|
zaidan: 'زيدان',
|
||||||
|
samer: 'سامر',
|
||||||
|
saif: 'سيف',
|
||||||
|
hossam: 'حسام',
|
||||||
|
bassem: 'باسم',
|
||||||
|
amjad: 'أمجد',
|
||||||
|
eyad: 'إياد',
|
||||||
|
hamza: 'حمزة',
|
||||||
|
haitham: 'هيثم',
|
||||||
|
wael: 'وائل',
|
||||||
|
raed: 'رائد',
|
||||||
|
fahad: 'فهد',
|
||||||
|
fares: 'فارس',
|
||||||
|
nasser: 'ناصر',
|
||||||
|
jameel: 'جميل',
|
||||||
|
}
|
||||||
|
|
||||||
|
const AR_TO_EN_CHAR = {
|
||||||
|
ا: 'a',
|
||||||
|
أ: 'a',
|
||||||
|
إ: 'i',
|
||||||
|
آ: 'aa',
|
||||||
|
ء: '',
|
||||||
|
ب: 'b',
|
||||||
|
ت: 't',
|
||||||
|
ث: 'th',
|
||||||
|
ج: 'j',
|
||||||
|
ح: 'h',
|
||||||
|
خ: 'kh',
|
||||||
|
د: 'd',
|
||||||
|
ذ: 'dh',
|
||||||
|
ر: 'r',
|
||||||
|
ز: 'z',
|
||||||
|
س: 's',
|
||||||
|
ش: 'sh',
|
||||||
|
ص: 's',
|
||||||
|
ض: 'd',
|
||||||
|
ط: 't',
|
||||||
|
ظ: 'z',
|
||||||
|
ع: 'a',
|
||||||
|
غ: 'gh',
|
||||||
|
ف: 'f',
|
||||||
|
ق: 'q',
|
||||||
|
ك: 'k',
|
||||||
|
ل: 'l',
|
||||||
|
م: 'm',
|
||||||
|
ن: 'n',
|
||||||
|
ه: 'h',
|
||||||
|
و: 'w',
|
||||||
|
ي: 'y',
|
||||||
|
ى: 'a',
|
||||||
|
ة: 'a',
|
||||||
|
ئ: 'e',
|
||||||
|
ؤ: 'o',
|
||||||
|
}
|
||||||
|
|
||||||
|
const EN_DIGRAPHS = [
|
||||||
|
['sh', 'ش'],
|
||||||
|
['kh', 'خ'],
|
||||||
|
['gh', 'غ'],
|
||||||
|
['th', 'ث'],
|
||||||
|
['dh', 'ذ'],
|
||||||
|
['ch', 'تش'],
|
||||||
|
['ph', 'ف'],
|
||||||
|
['aa', 'ا'],
|
||||||
|
['ee', 'ي'],
|
||||||
|
['oo', 'و'],
|
||||||
|
['ou', 'و'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const EN_TO_AR_CHAR = {
|
||||||
|
a: 'ا',
|
||||||
|
b: 'ب',
|
||||||
|
c: 'ك',
|
||||||
|
d: 'د',
|
||||||
|
e: 'ي',
|
||||||
|
f: 'ف',
|
||||||
|
g: 'ج',
|
||||||
|
h: 'ه',
|
||||||
|
i: 'ي',
|
||||||
|
j: 'ج',
|
||||||
|
k: 'ك',
|
||||||
|
l: 'ل',
|
||||||
|
m: 'م',
|
||||||
|
n: 'ن',
|
||||||
|
o: 'و',
|
||||||
|
p: 'ب',
|
||||||
|
q: 'ق',
|
||||||
|
r: 'ر',
|
||||||
|
s: 'س',
|
||||||
|
t: 'ت',
|
||||||
|
u: 'و',
|
||||||
|
v: 'ف',
|
||||||
|
w: 'و',
|
||||||
|
x: 'كس',
|
||||||
|
y: 'ي',
|
||||||
|
z: 'ز',
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeArabicWord(word) {
|
||||||
|
return String(word || '')
|
||||||
|
.replace(ARABIC_DIACRITICS, '')
|
||||||
|
.replace(/[\u0622\u0623\u0625]/g, 'ا')
|
||||||
|
.replace(/ة/g, 'ه')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(word) {
|
||||||
|
if (!word) return ''
|
||||||
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function transliterateArabicWord(word) {
|
||||||
|
const normalized = normalizeArabicWord(word)
|
||||||
|
if (!normalized) return ''
|
||||||
|
if (AR_TO_EN_WORD[normalized]) return AR_TO_EN_WORD[normalized]
|
||||||
|
|
||||||
|
let out = ''
|
||||||
|
for (const ch of normalized) {
|
||||||
|
out += AR_TO_EN_CHAR[ch] ?? ch
|
||||||
|
}
|
||||||
|
return titleCase(out.replace(/aa+/g, 'a'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function transliterateEnglishWord(word) {
|
||||||
|
const normalized = String(word || '').trim().toLowerCase()
|
||||||
|
if (!normalized) return ''
|
||||||
|
if (EN_TO_AR_WORD[normalized]) return EN_TO_AR_WORD[normalized]
|
||||||
|
|
||||||
|
let left = normalized
|
||||||
|
let out = ''
|
||||||
|
while (left.length > 0) {
|
||||||
|
let matched = false
|
||||||
|
for (const [latin, arabic] of EN_DIGRAPHS) {
|
||||||
|
if (left.startsWith(latin)) {
|
||||||
|
out += arabic
|
||||||
|
left = left.slice(latin.length)
|
||||||
|
matched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched) continue
|
||||||
|
out += EN_TO_AR_CHAR[left[0]] ?? left[0]
|
||||||
|
left = left.slice(1)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertNameAuto(direction, value) {
|
||||||
|
const words = String(value || '')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
if (words.length === 0) return ''
|
||||||
|
|
||||||
|
if (direction === 'ar_to_en') {
|
||||||
|
return words.map(transliterateArabicWord).filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
if (direction === 'en_to_ar') {
|
||||||
|
return words.map(transliterateEnglishWord).filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
return String(value || '')
|
||||||
|
}
|
||||||
|
|
||||||
59
frontend/src/utils/scoreGroupTheme.js
Normal file
59
frontend/src/utils/scoreGroupTheme.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { normalizedGroupCode } from './groups'
|
||||||
|
|
||||||
|
const PRESET = {
|
||||||
|
A: { accent: '#2f4d9a', soft: 'rgba(47, 77, 154, 0.10)', border: 'rgba(47, 77, 154, 0.34)' },
|
||||||
|
B: { accent: '#0d8fa5', soft: 'rgba(13, 143, 165, 0.10)', border: 'rgba(13, 143, 165, 0.34)' },
|
||||||
|
C: { accent: '#d54a3f', soft: 'rgba(213, 74, 63, 0.10)', border: 'rgba(213, 74, 63, 0.34)' },
|
||||||
|
D: { accent: '#c2891f', soft: 'rgba(194, 137, 31, 0.12)', border: 'rgba(194, 137, 31, 0.36)' },
|
||||||
|
F1: { accent: '#5b4fc9', soft: 'rgba(91, 79, 201, 0.11)', border: 'rgba(91, 79, 201, 0.36)' },
|
||||||
|
F2: { accent: '#0f9f63', soft: 'rgba(15, 159, 99, 0.11)', border: 'rgba(15, 159, 99, 0.36)' },
|
||||||
|
U: { accent: '#7f8ca8', soft: 'rgba(127, 140, 168, 0.11)', border: 'rgba(127, 140, 168, 0.34)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreGroupToken(row) {
|
||||||
|
const finalGroup = Number(row?.finalGroup || 0)
|
||||||
|
if (finalGroup === 1) return 'F1'
|
||||||
|
if (finalGroup === 2) return 'F2'
|
||||||
|
const code = normalizedGroupCode(row?.groupCode || '')
|
||||||
|
if (code) return code
|
||||||
|
return 'U'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreGroupStyle(row) {
|
||||||
|
const token = scoreGroupToken(row)
|
||||||
|
const preset = PRESET[token]
|
||||||
|
if (preset) {
|
||||||
|
return {
|
||||||
|
'--score-group-accent': preset.accent,
|
||||||
|
'--score-group-soft': preset.soft,
|
||||||
|
'--score-group-border': preset.border,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hue = hashToHue(token)
|
||||||
|
return {
|
||||||
|
'--score-group-accent': `hsl(${hue} 70% 38%)`,
|
||||||
|
'--score-group-soft': `hsla(${hue}, 72%, 46%, 0.10)`,
|
||||||
|
'--score-group-border': `hsla(${hue}, 70%, 38%, 0.34)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreValueStyle(scoreValue) {
|
||||||
|
const score = Number(scoreValue || 0)
|
||||||
|
const normalized = Number.isFinite(score) ? score : 0
|
||||||
|
const hue = hashToHue(`score:${normalized}`)
|
||||||
|
return {
|
||||||
|
'--score-group-accent': `hsl(${hue} 70% 38%)`,
|
||||||
|
'--score-group-soft': `hsla(${hue}, 72%, 46%, 0.10)`,
|
||||||
|
'--score-group-border': `hsla(${hue}, 70%, 38%, 0.34)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashToHue(value) {
|
||||||
|
const input = String(value || 'U')
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
hash = (hash * 31 + input.charCodeAt(i)) % 360
|
||||||
|
}
|
||||||
|
return (hash + 360) % 360
|
||||||
|
}
|
||||||
14
frontend/vite.config.js
Normal file
14
frontend/vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
BIN
phoenix/_build/dev/lib/bandit/.mix/compile.elixir
Normal file
BIN
phoenix/_build/dev/lib/bandit/.mix/compile.elixir
Normal file
Binary file not shown.
BIN
phoenix/_build/dev/lib/bandit/.mix/compile.elixir_scm
Normal file
BIN
phoenix/_build/dev/lib/bandit/.mix/compile.elixir_scm
Normal file
Binary file not shown.
0
phoenix/_build/dev/lib/bandit/.mix/compile.fetch
Normal file
0
phoenix/_build/dev/lib/bandit/.mix/compile.fetch
Normal file
BIN
phoenix/_build/dev/lib/bandit/ebin/Elixir.Bandit.Adapter.beam
Normal file
BIN
phoenix/_build/dev/lib/bandit/ebin/Elixir.Bandit.Adapter.beam
Normal file
Binary file not shown.
Binary file not shown.
BIN
phoenix/_build/dev/lib/bandit/ebin/Elixir.Bandit.Clock.beam
Normal file
BIN
phoenix/_build/dev/lib/bandit/ebin/Elixir.Bandit.Clock.beam
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
phoenix/_build/dev/lib/bandit/ebin/Elixir.Bandit.Extractor.beam
Normal file
BIN
phoenix/_build/dev/lib/bandit/ebin/Elixir.Bandit.Extractor.beam
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
phoenix/_build/dev/lib/bandit/ebin/Elixir.Bandit.HTTPError.beam
Normal file
BIN
phoenix/_build/dev/lib/bandit/ebin/Elixir.Bandit.HTTPError.beam
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
phoenix/_build/dev/lib/bandit/ebin/Elixir.Bandit.Headers.beam
Normal file
BIN
phoenix/_build/dev/lib/bandit/ebin/Elixir.Bandit.Headers.beam
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user