update
This commit is contained in:
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"]
|
||||||
56
Makefile
Normal file
56
Makefile
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin frontend/dist backend/web
|
||||||
151
README.md
151
README.md
@@ -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
|
||||||
|
}
|
||||||
76
backend/db.go
Normal file
76
backend/db.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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');
|
||||||
|
`
|
||||||
|
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=
|
||||||
461
backend/handlers.go
Normal file
461
backend/handlers.go
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
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 {
|
||||||
|
return writeError(c, http.StatusBadRequest, "viewProofInView is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.updateViewProofInView(*req.ViewProofInView); err != nil {
|
||||||
|
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 {
|
||||||
|
stage, err := validateStage(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")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.db.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 := a.db.Exec(`DELETE FROM score_attachments WHERE stage = ?`, stage); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage proofs: %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) 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
141
backend/models.go
Normal file
141
backend/models.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var scoreStages = []string{"preliminary", "prelim_tiebreak", "final", "final_tiebreak"}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
}
|
||||||
47
backend/settings.go
Normal file
47
backend/settings.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) readSettings() (AppSettings, error) {
|
||||||
|
var raw string
|
||||||
|
err := a.db.QueryRow(`SELECT value FROM app_settings WHERE key = 'view_proof_in_view'`).Scan(&raw)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return AppSettings{ViewProofInView: false}, nil
|
||||||
|
}
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting view_proof_in_view: %w", err)
|
||||||
|
}
|
||||||
|
return AppSettings{ViewProofInView: settingBool(raw)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateViewProofInView(enabled bool) error {
|
||||||
|
_, err := a.db.Exec(`
|
||||||
|
INSERT INTO app_settings(key, value, updated_at)
|
||||||
|
VALUES('view_proof_in_view', ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, settingString(enabled))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update app setting view_proof_in_view: %w", err)
|
||||||
|
}
|
||||||
|
return 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"
|
||||||
|
}
|
||||||
482
backend/state.go
Normal file
482
backend/state.go
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
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: scores["preliminary"][p.ID],
|
||||||
|
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: scores["final"][finalist.PlayerID],
|
||||||
|
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 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
|
||||||
1195
frontend/src/App.vue
Normal file
1195
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>
|
||||||
|
|
||||||
355
frontend/src/components/AdminPanel.vue
Normal file
355
frontend/src/components/AdminPanel.vue
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
<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 === '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>
|
||||||
|
|
||||||
|
<ScoreStageEditor
|
||||||
|
:t="t"
|
||||||
|
stage="preliminary"
|
||||||
|
:rows="preliminaryRows"
|
||||||
|
:filter-text="scoreFilters.preliminary"
|
||||||
|
:show-group="true"
|
||||||
|
:show-rank="true"
|
||||||
|
:input-label="t('table.score')"
|
||||||
|
: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: 'preliminary', value: $event })"
|
||||||
|
/>
|
||||||
|
</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"
|
||||||
|
:rows="prelimTieRows"
|
||||||
|
: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"
|
||||||
|
:placeholder="t('actions.searchPlayer')"
|
||||||
|
@input="$emit('update-score-filter', { stage: 'final', value: $event.target.value })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<div>
|
||||||
|
<h3 class="sub-heading">{{ t('labels.finalGroup1') }}</h3>
|
||||||
|
<ScoreStageEditor
|
||||||
|
:t="t"
|
||||||
|
stage="final"
|
||||||
|
:rows="finalGroup1"
|
||||||
|
:show-filter="false"
|
||||||
|
:filter-text="scoreFilters.final"
|
||||||
|
:show-seed="true"
|
||||||
|
:input-label="t('table.score')"
|
||||||
|
: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', value: $event })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="sub-heading">{{ t('labels.finalGroup2') }}</h3>
|
||||||
|
<ScoreStageEditor
|
||||||
|
:t="t"
|
||||||
|
stage="final"
|
||||||
|
:rows="finalGroup2"
|
||||||
|
:show-filter="false"
|
||||||
|
:filter-text="scoreFilters.final"
|
||||||
|
:show-seed="true"
|
||||||
|
:input-label="t('table.score')"
|
||||||
|
: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', value: $event })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
:rows="finalTieRows"
|
||||||
|
: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'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
adminTabs: { type: Array, required: true },
|
||||||
|
adminTab: { type: String, required: true },
|
||||||
|
viewProofInView: { type: Boolean, default: false },
|
||||||
|
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 },
|
||||||
|
preliminaryRows: { type: Array, required: true },
|
||||||
|
prelimTieRows: { type: Array, required: true },
|
||||||
|
finalGroup1: { type: Array, required: true },
|
||||||
|
finalGroup2: { type: Array, required: true },
|
||||||
|
finalTieRows: { type: Array, required: true },
|
||||||
|
finalRows: { type: Array, 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',
|
||||||
|
'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>
|
||||||
47
frontend/src/components/AppHeader.vue
Normal file
47
frontend/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<header class="masthead">
|
||||||
|
<div class="masthead-main">
|
||||||
|
<div>
|
||||||
|
<h1 class="masthead-title">{{ competitionTitle }}</h1>
|
||||||
|
<p class="masthead-subtitle">{{ t('subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="masthead-controls">
|
||||||
|
<div class="controls-grid">
|
||||||
|
<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="$emit('change-mode', 'view')">{{ t('viewMode') }}</button>
|
||||||
|
<button class="lang-btn" :class="{ active: mode === 'admin' }" @click="$emit('change-mode', '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="$emit('change-language', 'ar')">العربية</button>
|
||||||
|
<button class="lang-btn" :class="{ active: language === 'en' }" @click="$emit('change-language', 'en')">English</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-row">
|
||||||
|
<div class="live-badge">{{ mode === 'view' ? '● ' + t('labels.liveTracker') : t('adminPanel') }}</div>
|
||||||
|
<div class="server-time" v-if="serverTime">{{ t('labels.lastSync') }}: {{ serverTime }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
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: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['change-mode', 'change-language'])
|
||||||
|
</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>
|
||||||
|
|
||||||
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>
|
||||||
465
frontend/src/components/ViewModePanel.vue
Normal file
465
frontend/src/components/ViewModePanel.vue
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
<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 && hasScoreProof('preliminary', row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: 'preliminary', playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor('preliminary', 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>{{ scoreFor('final', row.playerId) }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && hasScoreProof('final', row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: 'final', playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor('final', 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>{{ scoreFor('final', row.playerId) }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && hasScoreProof('final', row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: 'final', playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor('final', 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 && hasScoreProof('final', row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: 'final', playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor('final', 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>
|
||||||
|
<p class="podium-score">{{ podiumScoreDisplay(podiumOrdered[0]) }}</p>
|
||||||
|
</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>
|
||||||
|
<p class="podium-score">{{ podiumScoreDisplay(podiumOrdered[1]) }}</p>
|
||||||
|
</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>
|
||||||
|
<p class="podium-score">{{ podiumScoreDisplay(podiumOrdered[2]) }}</p>
|
||||||
|
</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 },
|
||||||
|
scoreFor: { type: Function, required: true },
|
||||||
|
podiumOrdered: { type: Array, required: true },
|
||||||
|
podiumImage: { type: Function, required: true },
|
||||||
|
podiumName: { type: Function, required: true },
|
||||||
|
podiumHasResult: { type: Function, required: true },
|
||||||
|
podiumScoreDisplay: { 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>
|
||||||
259
frontend/src/components/admin/PlayersManagementTab.vue
Normal file
259
frontend/src/components/admin/PlayersManagementTab.vue
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
<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 class="mono">#{{ player.id }}</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>
|
||||||
202
frontend/src/components/admin/ScoreStageEditor.vue
Normal file
202
frontend/src/components/admin/ScoreStageEditor.vue
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<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 in filteredRows" :key="'desk-' + stage + '-' + row.playerId">
|
||||||
|
<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 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 in filteredRows" :key="'mob-' + stage + '-' + row.playerId" class="score-card">
|
||||||
|
<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">#{{ row.playerId }}</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'
|
||||||
|
|
||||||
|
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 },
|
||||||
|
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
|
||||||
|
})
|
||||||
|
</script>
|
||||||
313
frontend/src/constants/i18n.js
Normal file
313
frontend/src/constants/i18n.js
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
export const I18N = {
|
||||||
|
ar: {
|
||||||
|
titleFallback: 'بطولة دويتوايلر للرماية',
|
||||||
|
subtitle: 'فئة المسدس · مسافات: 15م ← 20م ← 25م',
|
||||||
|
viewMode: 'وضع العرض',
|
||||||
|
adminMode: 'لوحة الإدارة',
|
||||||
|
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: 'إظهار صور إثبات النتيجة في وضع العرض',
|
||||||
|
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: 'مساعد الذكاء الاصطناعي للنتيجة',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
competitor: 'اللاعب',
|
||||||
|
group: 'المجموعة',
|
||||||
|
rank: 'الترتيب',
|
||||||
|
score: 'النتيجة',
|
||||||
|
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: 'إعادة الضبط',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
username: 'اسم المستخدم',
|
||||||
|
password: 'كلمة المرور',
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
groups: 'المجموعات',
|
||||||
|
live: 'عرض حي',
|
||||||
|
overall: 'الترتيب العام',
|
||||||
|
final: 'النهائي',
|
||||||
|
podium: 'التتويج',
|
||||||
|
players: 'اللاعبون',
|
||||||
|
preliminary: 'التمهيدي',
|
||||||
|
prelimTie: 'كسر تعادل التأهل',
|
||||||
|
finalTie: 'كسر تعادل التتويج',
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
competitor: 'Player',
|
||||||
|
group: 'Group',
|
||||||
|
rank: 'Rank',
|
||||||
|
score: 'Score',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
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: {
|
||||||
|
preliminary: {},
|
||||||
|
prelim_tiebreak: {},
|
||||||
|
final: {},
|
||||||
|
final_tiebreak: {},
|
||||||
|
},
|
||||||
|
scoreProofs: {},
|
||||||
|
settings: { viewProofInView: false },
|
||||||
|
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')
|
||||||
1749
frontend/src/style.css
Normal file
1749
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 || '')
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user