Compare commits

9 Commits

Author SHA1 Message Date
f6df640bd5 update 2026-07-14 22:50:24 +04:00
6f8195cda1 last 2026-05-22 21:08:58 +04:00
2ccfac2d04 final 2026-05-20 10:34:32 +04:00
99d28c2114 update a lot 2026-05-19 13:56:13 +04:00
5863574a78 update 2026-05-18 13:22:11 +04:00
8cda54548f api specs 2026-04-29 00:45:48 +04:00
27143319e3 final 2026-04-03 09:55:36 +04:00
2465bc2ec3 update 2026-04-01 11:47:03 +04:00
cb68451c1c first commit 2026-03-31 12:50:37 +04:00
108 changed files with 18513 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.gitignore
node_modules
**/node_modules
backend/data
data
*.db
*.db-*
.DS_Store

1
.env Normal file
View File

@@ -0,0 +1 @@
GEMINI_API_KEY=AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc

6
.env.example Normal file
View 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
View 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

182
API_CHANGES.md Normal file
View File

@@ -0,0 +1,182 @@
# API Changes (Not Yet Reflected in `backend/openapi/openapi.json`)
This document lists additive API changes currently implemented in the Go backend.
## Summary
Existing endpoints remain compatible.
Added capabilities:
1. Position board persistence for live/admin ordering.
2. Stage session control (`start`/`end`) for event flow.
3. Stage history retrieval.
4. New state fields (`positionBoards`, `current_stage`, `last_stage`).
## 1) Position Board Endpoint
- Method: `PUT`
- Path: `/api/admin/positions/:board`
- Auth: admin bearer token (`Authorization: Bearer <token>`)
### Supported `:board` values
- `preliminary`
- `final`
- `prelim_tiebreak`
- `final_tiebreak`
### Request body
```json
{
"slots": [
{ "playerId": 12, "groupKey": "A", "position": 1 },
{ "playerId": 15, "groupKey": "A", "position": 2 },
{ "playerId": 20, "groupKey": "B", "position": 1 }
]
}
```
Fields:
- `playerId` (integer, required)
- `groupKey` (string, required)
- `position` (integer > 0, required)
Notes:
- The request replaces all saved slots for the target board.
- For `preliminary`, `players.group_code` is synchronized from `groupKey`.
- Special value `UNASSIGNED` maps to empty player group.
### Response
- `200 OK` with full updated admin state payload.
## 2) Stage Session Control Endpoints
### Start stage
- Method: `POST`
- Path: `/api/admin/stage/start`
- Auth: admin bearer token
Request body:
```json
{
"phase": "preliminary",
"group": "A",
"round": 2
}
```
Rules:
- `phase` must be one of:
- `preliminary`
- `prelim_tiebreak`
- `final`
- `final_tiebreak`
- `group` validation depends on phase:
- `preliminary`: group code string (or `UNASSIGNED`)
- `final`: `"1"` or `"2"`
- tie-break phases: positive numeric string
- `round` normalization:
- `preliminary`: `1..3`
- `final`: `1..2`
- tie-break phases: always `1`
- If another stage is active (started, not ended), API returns `409`.
Response:
- `200 OK` with full updated admin state payload.
### End stage
- Method: `POST`
- Path: `/api/admin/stage/end`
- Auth: admin bearer token
Rules:
- Ends the currently active stage by setting `ended_at`.
- If no active stage exists, API returns `400`.
Response:
- `200 OK` with full updated admin state payload.
## 3) Stage History Endpoint
- Method: `GET`
- Path: `/api/admin/stage/history`
- Auth: admin bearer token
Response:
```json
{
"items": [
{
"id": 1,
"phase": "preliminary",
"group": "A",
"round": 2,
"startedAt": "2026-04-30T08:27:13Z",
"endedAt": "2026-04-30T08:28:01Z"
}
]
}
```
## 4) New State Fields
Both endpoints now include these fields:
- `GET /api/state`
- `GET /api/admin/state`
```json
{
"positionBoards": {
"preliminary": {
"12": { "groupKey": "A", "position": 1 }
},
"final": {
"12": { "groupKey": "1", "position": 3 }
},
"prelim_tiebreak": {},
"final_tiebreak": {}
},
"current_stage": {
"id": 7,
"phase": "preliminary",
"group": "A",
"round": 2,
"startedAt": "2026-04-30T09:00:00Z"
},
"last_stage": {
"id": 6,
"phase": "final",
"group": "1",
"round": 1,
"startedAt": "2026-04-30T08:45:00Z",
"endedAt": "2026-04-30T08:55:00Z"
}
}
```
Notes:
- `positionBoards` keys are player IDs as strings.
- `current_stage` is `null` when no stage is active.
- `last_stage` is `null` when no stage session has ever started.
- `last_stage` is the most recently started session (ended or still active).
## Compatibility Impact
- Additive only; existing consumers remain functional.
- Consumers can ignore unknown fields safely.
- Consumers needing control/monitoring can use new stage endpoints and fields.

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1.7
# 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/ ./
# 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 bin/shooting-event-amd64 /app/shooting-event
COPY frontend/dist /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"]

44
Dockerfile.phoenix Normal file
View File

@@ -0,0 +1,44 @@
# 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 elixir:1.18.4-otp-28 AS phoenix-builder
ENV MIX_ENV=prod
WORKDIR /app/phoenix
RUN mix local.hex --force && mix local.rebar --force
COPY phoenix/mix.exs phoenix/mix.lock ./
RUN mix deps.get --only prod
RUN mix deps.compile
COPY phoenix/config ./config
COPY phoenix/lib ./lib
COPY phoenix/priv ./priv
RUN mix compile
RUN mix release
FROM --platform=$TARGETPLATFORM elixir:1.18.4-otp-28
RUN groupadd -r app && useradd -r -g app app
WORKDIR /app
COPY --from=phoenix-builder /app/phoenix/_build/prod/rel/shooting_event_phx /app
COPY --from=frontend-builder /app/frontend/dist /app/web
RUN mkdir -p /app/data && chown -R app:app /app
USER app
ENV PHX_SERVER=true
ENV PORT=8080
ENV DB_PATH=/app/data/shooting.db
ENV WEB_DIR=/app/web
ENV SECRET_KEY_BASE=pxh_v1_V2Y3TXF4Rk9OUm5FdlVQc2s3M2hqd0t1Q2pwYUx4N3Vjb2I5Wk1qXzJ5RWRmQnJ
EXPOSE 8080
ENTRYPOINT ["/app/bin/shooting_event_phx"]
CMD ["start"]

83
Makefile Normal file
View File

@@ -0,0 +1,83 @@
SHELL := /bin/bash
PNPM ?= pnpm
GO ?= go
CONTAINER_REG ?= repo.ssp-itinfra.com/admin
IMAGE_NAME ?= shooting-event-2
PHOENIX_IMAGE_NAME ?= $(IMAGE_NAME)-phoenix
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 phoenix-dev phoenix-build docker-build docker-run docker-build-phoenix docker-run-phoenix clean
install:
cd frontend && $(PNPM) install
cd backend && $(GO) mod tidy
cd phoenix && mix deps.get
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-backend-amd64:
mkdir -p bin
cd backend && GOARCH=amd64 GOOS=linux $(GO) build -o ../bin/shooting-event-amd64 .
build: build-frontend
rm -rf backend/web
mkdir -p backend/web
cp -R frontend/dist/. backend/web/
$(MAKE) build-backend
phoenix-dev:
cd phoenix && mix deps.get && mix ecto.create && mix ecto.migrate && PORT=$${PORT:-8080} mix phx.server
phoenix-build:
cd phoenix && MIX_ENV=prod mix deps.get --only prod && MIX_ENV=prod mix compile
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)
docker-build-phoenix:
docker buildx build --load --platform=linux/$(ARCH) -f Dockerfile.phoenix -t $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) .
docker tag $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-latest
docker tag $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-latest
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
docker-run-phoenix:
mkdir -p data
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)
release:
${MAKE} build
$(MAKE) docker-build
clean:
rm -rf bin frontend/dist backend/web

161
README.md Normal file
View File

@@ -0,0 +1,161 @@
# 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 Documentation (OpenAPI)
Interactive docs are now built in:
- Swagger UI: `GET /api/docs`
- OpenAPI JSON: `GET /api/openapi.json`
### Testing with Auth in Swagger UI
1. Open `/api/docs`.
2. Use the **Login & Authorize** form at the top:
- Username: `datwyler` (default)
- Password: `datwyler` (default)
3. The page calls `POST /api/admin/login`, retrieves the token, and auto-authorizes Swagger.
4. Use **Try it out** on any admin endpoint.
### Stage Values
For score update/proof/advice endpoints (`/api/admin/scores/{stage}/...`), use:
- `prelim_r1`
- `prelim_r2`
- `prelim_r3`
- `final_r1`
- `final_r2`
- `prelim_tiebreak`
- `final_tiebreak`
For reset endpoint (`POST /api/admin/scores/{stage}/reset`), use:
- `preliminary`
- `final`
- `prelim_tiebreak`
- `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
View File

@@ -0,0 +1 @@
GEMINI_API_KEY=AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc

95
backend/ai_handlers.go Normal file
View 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, 100)
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
}

145
backend/api_docs.go Normal file
View File

@@ -0,0 +1,145 @@
package main
import (
_ "embed"
"fmt"
"net/http"
"github.com/labstack/echo/v4"
)
//go:embed openapi/openapi.json
var openAPISpecJSON string
func (a *App) handleOpenAPISpec(c echo.Context) error {
return c.Blob(http.StatusOK, "application/json; charset=utf-8", []byte(openAPISpecJSON))
}
func (a *App) handleOpenAPIDocs(c echo.Context) error {
html := fmt.Sprintf(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shooting Event API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
body {
margin: 0;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f7f9fc;
}
.docs-top {
background: #ffffff;
border-bottom: 1px solid #dde5f3;
padding: 14px 16px;
}
.docs-title {
margin: 0 0 8px;
font-size: 18px;
color: #1b1f40;
}
.docs-help {
margin: 0;
font-size: 14px;
color: #4c5773;
}
.auth-panel {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.auth-panel input {
height: 36px;
border: 1px solid #cdd7eb;
border-radius: 8px;
padding: 0 10px;
font-size: 14px;
}
.auth-panel button {
height: 36px;
border: 1px solid #20284f;
background: #20284f;
color: white;
border-radius: 8px;
padding: 0 12px;
cursor: pointer;
}
.auth-status {
font-size: 13px;
color: #38446b;
}
#swagger-ui {
max-width: 1300px;
margin: 0 auto;
}
</style>
</head>
<body>
<section class="docs-top">
<h1 class="docs-title">Shooting Event OpenAPI Docs</h1>
<p class="docs-help">
Use <strong>Try it out</strong> for live testing. For admin endpoints: login with the form below,
then your token is auto-attached to Swagger Authorize.
</p>
<form id="auth-form" class="auth-panel">
<input id="admin-user" type="text" value="datwyler" autocomplete="username" placeholder="Admin username" />
<input id="admin-pass" type="password" value="datwyler" autocomplete="current-password" placeholder="Admin password" />
<button type="submit">Login & Authorize</button>
<span id="auth-status" class="auth-status"></span>
</form>
</section>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
const ui = SwaggerUIBundle({
url: %q,
dom_id: '#swagger-ui',
deepLinking: true,
displayRequestDuration: true,
persistAuthorization: true,
docExpansion: 'list',
tryItOutEnabled: true,
});
async function loginAndAuthorize(event) {
event.preventDefault();
const status = document.getElementById('auth-status');
status.textContent = 'Logging in...';
const username = document.getElementById('admin-user').value || '';
const password = document.getElementById('admin-pass').value || '';
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.message || 'Login failed');
}
if (!payload.token) {
throw new Error('Login succeeded but token was not returned');
}
ui.preauthorizeApiKey('bearerAuth', payload.token);
status.textContent = 'Authorized until ' + (payload.expiresAt || 'token expiry');
} catch (error) {
status.textContent = 'Auth failed: ' + error.message;
}
}
document.getElementById('auth-form').addEventListener('submit', loginAndAuthorize);
</script>
</body>
</html>`, "/api/openapi.json")
return c.HTML(http.StatusOK, html)
}

93
backend/auth.go Normal file
View 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
View 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
}

118
backend/db.go Normal file
View File

@@ -0,0 +1,118 @@
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 player_positions (
board TEXT NOT NULL,
player_id INTEGER NOT NULL,
group_key TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(board, 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
);
CREATE TABLE IF NOT EXISTS stage_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
phase TEXT NOT NULL,
group_key TEXT NOT NULL DEFAULT '',
round INTEGER NOT NULL DEFAULT 1,
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at DATETIME
);
INSERT OR IGNORE INTO app_settings(key, value) VALUES
('view_proof_in_view', '0'),
('live_active_view', 'group_live'),
('live_show_group_live', '1'),
('live_group_display_mode', 'rotate'),
('live_group_fixed_code', ''),
('live_show_prelim_tie', '1'),
('live_show_prelim_overall', '1'),
('live_show_final_groups', '1'),
('live_final_display_mode', 'rotate'),
('live_final_fixed_group', '1'),
('live_show_final_tie', '1'),
('live_show_podium', '1'),
('live_rotation_interval_sec', '5'),
('live_rotation_player_count', '12');
INSERT OR IGNORE INTO scores(stage, player_id, score)
SELECT 'prelim_r1', player_id, score FROM scores WHERE stage = 'preliminary';
INSERT OR IGNORE INTO scores(stage, player_id, score)
SELECT 'final_r1', player_id, score FROM scores WHERE stage = 'final';
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
SELECT 'prelim_r1', player_id, image_data FROM score_attachments WHERE stage = 'preliminary';
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
SELECT 'final_r1', player_id, image_data FROM score_attachments WHERE stage = 'final';
`
if _, err := db.Exec(schema); err != nil {
return nil, fmt.Errorf("apply schema: %w", err)
}
return db, nil
}

83
backend/events.go Normal file
View 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
View 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..100>,"reason":"<max 18 words>"}
Do not add markdown or extra fields.`, stage, currentScore)
}

29
backend/go.mod Normal file
View 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
View 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=

643
backend/handlers.go Normal file
View File

@@ -0,0 +1,643 @@
package main
import (
"errors"
"fmt"
"io"
"math/rand"
"net/http"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
)
func (a *App) handleHealth(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
func (a *App) handleGetState(c echo.Context) error {
state, err := a.readState(a.isAdminRequest(c))
if err != nil {
return writeError(c, http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, state)
}
func (a *App) handleAdminLogin(c echo.Context) error {
var req AdminLoginRequest
if err := c.Bind(&req); err != nil {
return writeError(c, http.StatusBadRequest, "invalid request body")
}
if !a.verifyAdmin(req.Username, req.Password) {
return writeError(c, http.StatusUnauthorized, "invalid credentials")
}
token, expiry, err := a.sessions.CreateToken()
if err != nil {
return writeError(c, http.StatusInternalServerError, "failed to create session")
}
return c.JSON(http.StatusOK, map[string]any{
"token": token,
"expiresAt": expiry.Format(time.RFC3339),
"username": a.cfg.AdminUser,
})
}
func (a *App) handleAdminLogout(c echo.Context) error {
token := strings.TrimSpace(strings.TrimPrefix(c.Request().Header.Get(echo.HeaderAuthorization), "Bearer "))
a.sessions.DeleteToken(token)
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
}
func (a *App) handleUpdateAdminSettings(c echo.Context) error {
var req AdminSettingsUpdateRequest
if err := c.Bind(&req); err != nil {
return writeError(c, http.StatusBadRequest, "invalid request body")
}
if req.ViewProofInView == nil && req.LiveMode == nil {
return writeError(c, http.StatusBadRequest, "at least one settings field is required")
}
if err := a.updateSettings(req); err != nil {
if strings.Contains(err.Error(), "no settings to update") {
return writeError(c, http.StatusBadRequest, err.Error())
}
return writeError(c, http.StatusInternalServerError, err.Error())
}
a.events.Broadcast()
state, err := a.readState(true)
if err != nil {
return writeError(c, http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, state)
}
func (a *App) handleStartCurrentStage(c echo.Context) error {
var req StageControlStartRequest
if err := c.Bind(&req); err != nil {
return writeError(c, http.StatusBadRequest, "invalid request body")
}
phase, ok := normalizeStagePhase(req.Phase)
if !ok {
return writeError(c, http.StatusBadRequest, "invalid phase")
}
groupKey, ok := normalizeStageGroup(phase, req.GroupKey)
if !ok {
return writeError(c, http.StatusBadRequest, "invalid group for selected phase")
}
round := normalizeStageRound(phase, req.Round)
current, err := a.readCurrentStage()
if err != nil {
return writeError(c, http.StatusInternalServerError, err.Error())
}
if current != nil {
return writeError(c, http.StatusConflict, "there is already an active stage. end it first")
}
_, err = a.db.Exec(`
INSERT INTO stage_sessions(phase, group_key, round, started_at)
VALUES(?, ?, ?, CURRENT_TIMESTAMP)
`, phase, groupKey, round)
if err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("start stage: %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) handleEndCurrentStage(c echo.Context) error {
current, err := a.readCurrentStage()
if err != nil {
return writeError(c, http.StatusInternalServerError, err.Error())
}
if current == nil {
return writeError(c, http.StatusBadRequest, "no active stage to end")
}
_, err = a.db.Exec(`
UPDATE stage_sessions
SET ended_at = CURRENT_TIMESTAMP
WHERE id = ? AND ended_at IS NULL
`, current.ID)
if err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("end stage: %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) handleGetStageHistory(c echo.Context) error {
items, err := a.readStageHistory()
if err != nil {
return writeError(c, http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, map[string]any{
"items": items,
})
}
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) handleUpdatePositionBoard(c echo.Context) error {
board, ok := normalizePositionBoard(c.Param("board"))
if !ok {
return writeError(c, http.StatusBadRequest, "invalid position board")
}
var req PositionBoardUpdateRequest
if err := c.Bind(&req); err != nil {
return writeError(c, http.StatusBadRequest, "invalid request body")
}
seen := map[int]bool{}
for _, slot := range req.Slots {
if slot.PlayerID <= 0 {
return writeError(c, http.StatusBadRequest, "invalid player id")
}
if seen[slot.PlayerID] {
return writeError(c, http.StatusBadRequest, "duplicate player id in slots")
}
seen[slot.PlayerID] = true
if strings.TrimSpace(slot.GroupKey) == "" {
return writeError(c, http.StatusBadRequest, "groupKey is required")
}
if slot.Position <= 0 {
return writeError(c, http.StatusBadRequest, "position must be positive")
}
}
tx, err := a.db.Begin()
if err != nil {
return writeError(c, http.StatusInternalServerError, "failed to start position transaction")
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM player_positions WHERE board = ?`, board); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("clear board positions: %v", err))
}
for _, slot := range req.Slots {
groupKey := normalizePositionGroupKey(board, slot.GroupKey, "")
if _, err := tx.Exec(`
INSERT INTO player_positions(board, player_id, group_key, position, updated_at)
VALUES(?, ?, ?, ?, CURRENT_TIMESTAMP)
`, board, slot.PlayerID, groupKey, slot.Position); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("save board position: %v", err))
}
if board == positionBoardPreliminary {
groupCode := strings.TrimSpace(groupKey)
if strings.EqualFold(groupCode, positionBoardUnassigned) {
groupCode = ""
}
if _, err := tx.Exec(`UPDATE players SET group_code = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, groupCode, slot.PlayerID); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update player group from board: %v", err))
}
}
}
if err := tx.Commit(); err != nil {
return writeError(c, http.StatusInternalServerError, "failed to commit positions")
}
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 > 100 {
return writeError(c, http.StatusBadRequest, "score must be between 0 and 100")
}
res, err := a.db.Exec(`
INSERT INTO scores(stage, player_id, score)
VALUES(?, ?, ?)
ON CONFLICT(stage, player_id) DO UPDATE SET score = excluded.score, updated_at = CURRENT_TIMESTAMP
`, stage, playerID, req.Score)
if err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update score: %v", err))
}
if _, err := res.RowsAffected(); err != nil {
return writeError(c, http.StatusInternalServerError, "failed to check score update")
}
a.events.Broadcast()
state, err := a.readState(true)
if err != nil {
return writeError(c, http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, state)
}
func (a *App) handleResetStageScores(c echo.Context) error {
targetStages, err := resolveResetStages(c.Param("stage"))
if err != nil {
return writeError(c, http.StatusBadRequest, err.Error())
}
var req ResetStageRequest
if err := c.Bind(&req); err != nil && !errors.Is(err, io.EOF) {
return writeError(c, http.StatusBadRequest, "invalid request body")
}
tx, err := a.db.Begin()
if err != nil {
return writeError(c, http.StatusInternalServerError, "failed to start reset transaction")
}
defer tx.Rollback()
for _, stage := range targetStages {
if _, err := tx.Exec(`UPDATE scores SET score = 0, updated_at = CURRENT_TIMESTAMP WHERE stage = ?`, stage); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage scores: %v", err))
}
if req.ResetProofs {
if _, err := tx.Exec(`DELETE FROM score_attachments WHERE stage = ?`, stage); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage proofs: %v", err))
}
}
}
if err := tx.Commit(); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("commit stage reset: %v", err))
}
a.events.Broadcast()
state, err := a.readState(true)
if err != nil {
return writeError(c, http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, state)
}
func resolveResetStages(stage string) ([]string, error) {
switch strings.ToLower(strings.TrimSpace(stage)) {
case "preliminary":
return append([]string{}, preliminaryRoundStages...), nil
case "final":
stages := append([]string{}, finalRoundStages...)
stages = append(stages, "final_tiebreak")
return stages, nil
case "prelim_tiebreak", "final_tiebreak":
normalized, err := validateStage(stage)
if err != nil {
return nil, err
}
return []string{normalized}, nil
default:
return nil, fmt.Errorf("invalid stage")
}
}
func (a *App) handleUpdateScoreProof(c echo.Context) error {
stage, err := validateStage(c.Param("stage"))
if err != nil {
return writeError(c, http.StatusBadRequest, err.Error())
}
playerID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return writeError(c, http.StatusBadRequest, "invalid player id")
}
var req ScoreProofUpdateRequest
if err := c.Bind(&req); err != nil {
return writeError(c, http.StatusBadRequest, "invalid request body")
}
imageData := strings.TrimSpace(req.ImageData)
if imageData == "" {
return writeError(c, http.StatusBadRequest, "imageData is required")
}
if len(imageData) > 5_000_000 {
return writeError(c, http.StatusBadRequest, "image payload too large")
}
if _, err := a.db.Exec(`
INSERT INTO score_attachments(stage, player_id, image_data)
VALUES(?, ?, ?)
ON CONFLICT(stage, player_id) DO UPDATE SET image_data = excluded.image_data, updated_at = CURRENT_TIMESTAMP
`, stage, playerID, imageData); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update score proof: %v", err))
}
a.events.Broadcast()
state, err := a.readState(true)
if err != nil {
return writeError(c, http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, state)
}
func (a *App) handleDeleteScoreProof(c echo.Context) error {
stage, err := validateStage(c.Param("stage"))
if err != nil {
return writeError(c, http.StatusBadRequest, err.Error())
}
playerID, err := strconv.Atoi(c.Param("id"))
if err != nil {
return writeError(c, http.StatusBadRequest, "invalid player id")
}
if _, err := a.db.Exec(`DELETE FROM score_attachments WHERE stage = ? AND player_id = ?`, stage, playerID); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("delete score proof: %v", err))
}
a.events.Broadcast()
state, err := a.readState(true)
if err != nil {
return writeError(c, http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, state)
}
func validateStage(stage string) (string, error) {
normalized := strings.ToLower(strings.TrimSpace(stage))
for _, allowed := range scoreStages {
if normalized == allowed {
return normalized, nil
}
}
return "", fmt.Errorf("invalid stage")
}
func normalizeGroup(group string) string {
return strings.TrimSpace(group)
}
func normalizeGroups(groups []string) []string {
seen := map[string]bool{}
out := []string{}
for _, group := range groups {
normalized := normalizeGroup(group)
if normalized == "" || seen[normalized] {
continue
}
seen[normalized] = true
out = append(out, normalized)
}
return out
}
func writeError(c echo.Context, status int, message string) error {
return c.JSON(status, map[string]string{"message": message})
}

54
backend/main.go Normal file
View 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)
}
}

212
backend/models.go Normal file
View File

@@ -0,0 +1,212 @@
package main
import (
"database/sql"
"sync"
"time"
)
var preliminaryRoundStages = []string{"prelim_r1", "prelim_r2", "prelim_r3"}
var finalRoundStages = []string{"final_r1", "final_r2"}
var tieBreakStages = []string{"prelim_tiebreak", "final_tiebreak"}
var scoreStages = append(append(append([]string{}, preliminaryRoundStages...), finalRoundStages...), tieBreakStages...)
type App struct {
db *sql.DB
cfg Config
sessions *SessionStore
events *EventHub
}
type SessionStore struct {
mu sync.RWMutex
tokens map[string]time.Time
duration time.Duration
}
type EventHub struct {
mu sync.RWMutex
nextID int
subscribers map[int]chan struct{}
}
type CompetitionMeta struct {
TitleAr string `json:"titleAr"`
TitleEn string `json:"titleEn"`
}
type Player struct {
ID int `json:"id"`
NameAr string `json:"nameAr"`
NameEn string `json:"nameEn"`
GroupCode string `json:"groupCode"`
ImageData string `json:"imageData"`
}
type RankingRow struct {
PlayerID int `json:"playerId"`
NameAr string `json:"nameAr"`
NameEn string `json:"nameEn"`
GroupCode string `json:"groupCode"`
ImageData string `json:"imageData"`
Score int `json:"score"`
TieBreak int `json:"tieBreak"`
Rank int `json:"rank"`
Seed int `json:"seed"`
FinalGroup int `json:"finalGroup"`
}
type TieBreakInfo struct {
Required bool `json:"required"`
Resolved bool `json:"resolved"`
Slots int `json:"slots"`
PlayerIDs []int `json:"playerIds"`
}
type DerivedState struct {
PreliminaryRanking RankingBundle `json:"preliminaryRanking"`
Finalists []RankingRow `json:"finalists"`
FinalGroups FinalGroups `json:"finalGroups"`
FinalRanking RankingBundle `json:"finalRanking"`
Podium []RankingRow `json:"podium"`
}
type RankingBundle struct {
Rows []RankingRow `json:"rows"`
TieBreak TieBreakInfo `json:"tieBreak"`
Unresolved bool `json:"unresolved"`
}
type FinalGroups struct {
Group1 []RankingRow `json:"group1"`
Group2 []RankingRow `json:"group2"`
}
type StateResponse struct {
Competition CompetitionMeta `json:"competition"`
Players []Player `json:"players"`
Scores map[string]map[string]int `json:"scores"`
ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"`
PositionBoards map[string]map[string]PositionSlot `json:"positionBoards"`
Settings AppSettings `json:"settings"`
Derived DerivedState `json:"derived"`
CurrentStage *StageSessionState `json:"current_stage"`
LastStage *StageSessionState `json:"last_stage"`
ServerTime string `json:"serverTime"`
}
type StageSessionState struct {
ID int `json:"id"`
Phase string `json:"phase"`
GroupKey string `json:"group"`
Round int `json:"round"`
StartedAt string `json:"startedAt"`
EndedAt string `json:"endedAt,omitempty"`
}
type PositionSlot struct {
GroupKey string `json:"groupKey"`
Position int `json:"position"`
}
type AppSettings struct {
ViewProofInView bool `json:"viewProofInView"`
LiveMode LiveModeSettings `json:"liveMode"`
}
type LiveModeSettings struct {
ActiveView string `json:"activeView"`
ShowGroupLive bool `json:"showGroupLive"`
GroupDisplayMode string `json:"groupDisplayMode"`
GroupFixedCode string `json:"groupFixedCode"`
ShowPrelimTie bool `json:"showPrelimTie"`
ShowPrelimOverall bool `json:"showPrelimOverall"`
ShowFinalGroups bool `json:"showFinalGroups"`
FinalDisplayMode string `json:"finalDisplayMode"`
FinalFixedGroup int `json:"finalFixedGroup"`
ShowFinalTie bool `json:"showFinalTie"`
ShowPodium bool `json:"showPodium"`
RotationIntervalSec int `json:"rotationIntervalSec"`
RotationPlayerCount int `json:"rotationPlayerCount"`
}
type AdminLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type PlayerCreateRequest struct {
NameAr string `json:"nameAr"`
NameEn string `json:"nameEn"`
GroupCode string `json:"groupCode"`
}
type PlayerUpdateRequest struct {
NameAr *string `json:"nameAr"`
NameEn *string `json:"nameEn"`
GroupCode *string `json:"groupCode"`
ImageData *string `json:"imageData"`
ClearImage bool `json:"clearImage"`
}
type ScoreUpdateRequest struct {
Score int `json:"score"`
}
type ScoreProofUpdateRequest struct {
ImageData string `json:"imageData"`
}
type AdminSettingsUpdateRequest struct {
ViewProofInView *bool `json:"viewProofInView"`
LiveMode *LiveModeSettingsUpdateRequest `json:"liveMode"`
}
type LiveModeSettingsUpdateRequest struct {
ActiveView *string `json:"activeView"`
ShowGroupLive *bool `json:"showGroupLive"`
GroupDisplayMode *string `json:"groupDisplayMode"`
GroupFixedCode *string `json:"groupFixedCode"`
ShowPrelimTie *bool `json:"showPrelimTie"`
ShowPrelimOverall *bool `json:"showPrelimOverall"`
ShowFinalGroups *bool `json:"showFinalGroups"`
FinalDisplayMode *string `json:"finalDisplayMode"`
FinalFixedGroup *int `json:"finalFixedGroup"`
ShowFinalTie *bool `json:"showFinalTie"`
ShowPodium *bool `json:"showPodium"`
RotationIntervalSec *int `json:"rotationIntervalSec"`
RotationPlayerCount *int `json:"rotationPlayerCount"`
}
type ResetStageRequest struct {
ResetProofs bool `json:"resetProofs"`
}
type AutoGroupPlayersRequest struct {
Groups []string `json:"groups"`
}
type PositionBoardUpdateRequest struct {
Slots []PositionBoardSlotInput `json:"slots"`
}
type PositionBoardSlotInput struct {
PlayerID int `json:"playerId"`
GroupKey string `json:"groupKey"`
Position int `json:"position"`
}
type ScoreAdviceResponse struct {
Stage string `json:"stage"`
PlayerID int `json:"playerId"`
AdvisedScore int `json:"advisedScore"`
Reason string `json:"reason"`
GeneratedAt string `json:"generatedAt"`
}
type StageControlStartRequest struct {
Phase string `json:"phase"`
GroupKey string `json:"group"`
Round int `json:"round"`
}

1410
backend/openapi/openapi.json Normal file

File diff suppressed because it is too large Load Diff

65
backend/routes.go Normal file
View File

@@ -0,0 +1,65 @@
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("/docs", app.handleOpenAPIDocs)
api.GET("/docs/", app.handleOpenAPIDocs)
api.GET("/openapi.json", app.handleOpenAPISpec)
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.GET("/admin/stage/history", app.handleGetStageHistory, app.adminOnly)
api.PUT("/admin/settings", app.handleUpdateAdminSettings, app.adminOnly)
api.POST("/admin/stage/start", app.handleStartCurrentStage, app.adminOnly)
api.POST("/admin/stage/end", app.handleEndCurrentStage, app.adminOnly)
api.POST("/admin/players", app.handleCreatePlayer, app.adminOnly)
api.POST("/admin/players/auto-group", app.handleAutoGroupPlayers, app.adminOnly)
api.PUT("/admin/positions/:board", app.handleUpdatePositionBoard, app.adminOnly)
api.PUT("/admin/players/:id", app.handleUpdatePlayer, app.adminOnly)
api.DELETE("/admin/players/:id", app.handleDeletePlayer, app.adminOnly)
api.PUT("/admin/scores/:stage/:id", app.handleUpdateScore, app.adminOnly)
api.PUT("/admin/scores/:stage/:id/proof", app.handleUpdateScoreProof, app.adminOnly)
api.DELETE("/admin/scores/:stage/:id/proof", app.handleDeleteScoreProof, app.adminOnly)
api.POST("/admin/scores/:stage/:id/advice", app.handleScoreAdvice, app.adminOnly)
api.POST("/admin/scores/:stage/reset", app.handleResetStageScores, app.adminOnly)
}
func registerWebRoutes(e *echo.Echo, cfg Config) {
e.GET("/*", func(c echo.Context) error {
requestPath := strings.TrimPrefix(c.Param("*"), "/")
if strings.HasPrefix(requestPath, "api") {
return echo.ErrNotFound
}
if requestPath == "" {
requestPath = "index.html"
}
clean := strings.TrimPrefix(filepath.Clean("/"+requestPath), "/")
assetPath := filepath.Join(cfg.WebDir, clean)
if stat, err := os.Stat(assetPath); err == nil && !stat.IsDir() {
return c.File(assetPath)
}
indexPath := filepath.Join(cfg.WebDir, "index.html")
if stat, err := os.Stat(indexPath); err == nil && !stat.IsDir() {
return c.File(indexPath)
}
return c.String(http.StatusNotFound, "frontend build not found. run make build")
})
}

366
backend/settings.go Normal file
View File

@@ -0,0 +1,366 @@
package main
import (
"database/sql"
"fmt"
"strconv"
"strings"
)
const (
settingViewProofInView = "view_proof_in_view"
settingLiveActiveView = "live_active_view"
settingLiveShowGroupLive = "live_show_group_live"
settingLiveGroupDisplayMode = "live_group_display_mode"
settingLiveGroupFixedCode = "live_group_fixed_code"
settingLiveShowPrelimTie = "live_show_prelim_tie"
settingLiveShowPrelimOverall = "live_show_prelim_overall"
settingLiveShowFinalGroups = "live_show_final_groups"
settingLiveFinalDisplayMode = "live_final_display_mode"
settingLiveFinalFixedGroup = "live_final_fixed_group"
settingLiveShowFinalTie = "live_show_final_tie"
settingLiveShowPodium = "live_show_podium"
settingLiveRotationInterval = "live_rotation_interval_sec"
settingLiveRotationPlayers = "live_rotation_player_count"
)
func defaultLiveModeSettings() LiveModeSettings {
return LiveModeSettings{
ActiveView: "group_live",
ShowGroupLive: true,
GroupDisplayMode: "rotate",
GroupFixedCode: "",
ShowPrelimTie: true,
ShowPrelimOverall: true,
ShowFinalGroups: true,
FinalDisplayMode: "rotate",
FinalFixedGroup: 1,
ShowFinalTie: true,
ShowPodium: true,
RotationIntervalSec: 5,
RotationPlayerCount: 12,
}
}
func (a *App) readSettings() (AppSettings, error) {
live := defaultLiveModeSettings()
viewProofRaw, err := a.readSettingValue(settingViewProofInView)
if err != nil {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingViewProofInView, err)
}
if raw, err := a.readSettingValue(settingLiveShowGroupLive); err == nil {
live.ShowGroupLive = settingBool(raw)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowGroupLive, err)
}
if raw, err := a.readSettingValue(settingLiveActiveView); err == nil {
live.ActiveView = normalizeLiveActiveView(raw, live.ActiveView)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveActiveView, err)
}
if raw, err := a.readSettingValue(settingLiveGroupDisplayMode); err == nil {
live.GroupDisplayMode = normalizeLiveDisplayMode(raw, live.GroupDisplayMode)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveGroupDisplayMode, err)
}
if raw, err := a.readSettingValue(settingLiveGroupFixedCode); err == nil {
live.GroupFixedCode = strings.ToUpper(strings.TrimSpace(raw))
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveGroupFixedCode, err)
}
if raw, err := a.readSettingValue(settingLiveShowPrelimTie); err == nil {
live.ShowPrelimTie = settingBool(raw)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowPrelimTie, err)
}
if raw, err := a.readSettingValue(settingLiveShowPrelimOverall); err == nil {
live.ShowPrelimOverall = settingBool(raw)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowPrelimOverall, err)
}
if raw, err := a.readSettingValue(settingLiveShowFinalGroups); err == nil {
live.ShowFinalGroups = settingBool(raw)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowFinalGroups, err)
}
if raw, err := a.readSettingValue(settingLiveFinalDisplayMode); err == nil {
live.FinalDisplayMode = normalizeLiveDisplayMode(raw, live.FinalDisplayMode)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveFinalDisplayMode, err)
}
if raw, err := a.readSettingValue(settingLiveFinalFixedGroup); err == nil {
live.FinalFixedGroup = normalizeLiveFinalFixedGroup(raw, live.FinalFixedGroup)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveFinalFixedGroup, err)
}
if raw, err := a.readSettingValue(settingLiveShowFinalTie); err == nil {
live.ShowFinalTie = settingBool(raw)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowFinalTie, err)
}
if raw, err := a.readSettingValue(settingLiveShowPodium); err == nil {
live.ShowPodium = settingBool(raw)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowPodium, err)
}
if raw, err := a.readSettingValue(settingLiveRotationInterval); err == nil {
live.RotationIntervalSec = normalizeRotationInterval(raw, live.RotationIntervalSec)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveRotationInterval, err)
}
if raw, err := a.readSettingValue(settingLiveRotationPlayers); err == nil {
live.RotationPlayerCount = normalizeRotationPlayerCount(raw, live.RotationPlayerCount)
} else {
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveRotationPlayers, err)
}
return AppSettings{
ViewProofInView: settingBool(viewProofRaw),
LiveMode: live,
}, nil
}
func (a *App) updateSettings(req AdminSettingsUpdateRequest) error {
tx, err := a.db.Begin()
if err != nil {
return fmt.Errorf("begin settings transaction: %w", err)
}
defer tx.Rollback()
hasUpdate := false
if req.ViewProofInView != nil {
hasUpdate = true
if err := upsertSetting(tx, settingViewProofInView, settingString(*req.ViewProofInView)); err != nil {
return err
}
}
if req.LiveMode != nil {
patch := req.LiveMode
if patch.ActiveView != nil {
hasUpdate = true
value := normalizeLiveActiveView(*patch.ActiveView, "group_live")
if err := upsertSetting(tx, settingLiveActiveView, value); err != nil {
return err
}
}
if patch.ShowGroupLive != nil {
hasUpdate = true
if err := upsertSetting(tx, settingLiveShowGroupLive, settingString(*patch.ShowGroupLive)); err != nil {
return err
}
}
if patch.GroupDisplayMode != nil {
hasUpdate = true
value := normalizeLiveDisplayMode(*patch.GroupDisplayMode, "rotate")
if err := upsertSetting(tx, settingLiveGroupDisplayMode, value); err != nil {
return err
}
}
if patch.GroupFixedCode != nil {
hasUpdate = true
value := strings.ToUpper(strings.TrimSpace(*patch.GroupFixedCode))
if err := upsertSetting(tx, settingLiveGroupFixedCode, value); err != nil {
return err
}
}
if patch.ShowPrelimTie != nil {
hasUpdate = true
if err := upsertSetting(tx, settingLiveShowPrelimTie, settingString(*patch.ShowPrelimTie)); err != nil {
return err
}
}
if patch.ShowPrelimOverall != nil {
hasUpdate = true
if err := upsertSetting(tx, settingLiveShowPrelimOverall, settingString(*patch.ShowPrelimOverall)); err != nil {
return err
}
}
if patch.ShowFinalGroups != nil {
hasUpdate = true
if err := upsertSetting(tx, settingLiveShowFinalGroups, settingString(*patch.ShowFinalGroups)); err != nil {
return err
}
}
if patch.FinalDisplayMode != nil {
hasUpdate = true
value := normalizeLiveDisplayMode(*patch.FinalDisplayMode, "rotate")
if err := upsertSetting(tx, settingLiveFinalDisplayMode, value); err != nil {
return err
}
}
if patch.FinalFixedGroup != nil {
hasUpdate = true
value := normalizeLiveFinalFixedGroupInt(*patch.FinalFixedGroup)
if err := upsertSetting(tx, settingLiveFinalFixedGroup, strconv.Itoa(value)); err != nil {
return err
}
}
if patch.ShowFinalTie != nil {
hasUpdate = true
if err := upsertSetting(tx, settingLiveShowFinalTie, settingString(*patch.ShowFinalTie)); err != nil {
return err
}
}
if patch.ShowPodium != nil {
hasUpdate = true
if err := upsertSetting(tx, settingLiveShowPodium, settingString(*patch.ShowPodium)); err != nil {
return err
}
}
if patch.RotationIntervalSec != nil {
hasUpdate = true
value := normalizeRotationIntervalInt(*patch.RotationIntervalSec)
if err := upsertSetting(tx, settingLiveRotationInterval, strconv.Itoa(value)); err != nil {
return err
}
}
if patch.RotationPlayerCount != nil {
hasUpdate = true
value := normalizeRotationPlayerCountInt(*patch.RotationPlayerCount)
if err := upsertSetting(tx, settingLiveRotationPlayers, strconv.Itoa(value)); err != nil {
return err
}
}
}
if !hasUpdate {
return fmt.Errorf("no settings to update")
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit settings transaction: %w", err)
}
return nil
}
func upsertSetting(tx *sql.Tx, key string, value string) error {
_, err := tx.Exec(`
INSERT INTO app_settings(key, value, updated_at)
VALUES(?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
`, key, value)
if err != nil {
return fmt.Errorf("update app setting %s: %w", key, err)
}
return nil
}
func (a *App) readSettingValue(key string) (string, error) {
var raw string
err := a.db.QueryRow(`SELECT value FROM app_settings WHERE key = ?`, key).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
return "", nil
}
return "", err
}
return raw, nil
}
func settingBool(value string) bool {
switch strings.TrimSpace(strings.ToLower(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func settingString(value bool) string {
if value {
return "1"
}
return "0"
}
func normalizeLiveDisplayMode(value string, fallback string) string {
normalized := strings.TrimSpace(strings.ToLower(value))
if normalized == "fixed" {
return "fixed"
}
if normalized == "rotate" {
return "rotate"
}
if strings.TrimSpace(strings.ToLower(fallback)) == "fixed" {
return "fixed"
}
return "rotate"
}
func normalizeLiveFinalFixedGroup(value string, fallback int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return normalizeLiveFinalFixedGroupInt(fallback)
}
return normalizeLiveFinalFixedGroupInt(parsed)
}
func normalizeLiveFinalFixedGroupInt(value int) int {
if value == 2 {
return 2
}
return 1
}
func normalizeRotationInterval(raw string, fallback int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return normalizeRotationIntervalInt(fallback)
}
return normalizeRotationIntervalInt(parsed)
}
func normalizeRotationIntervalInt(value int) int {
if value < 3 {
return 3
}
if value > 30 {
return 30
}
return value
}
func normalizeRotationPlayerCount(raw string, fallback int) int {
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil {
return normalizeRotationPlayerCountInt(fallback)
}
return normalizeRotationPlayerCountInt(parsed)
}
func normalizeRotationPlayerCountInt(value int) int {
if value < 3 {
return 3
}
if value > 40 {
return 40
}
return value
}
func normalizeLiveActiveView(value string, fallback string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "final_overall", "podium":
return strings.TrimSpace(strings.ToLower(value))
default:
if strings.TrimSpace(strings.ToLower(fallback)) != "" {
return strings.TrimSpace(strings.ToLower(fallback))
}
return "group_live"
}
}

172
backend/stage_sessions.go Normal file
View File

@@ -0,0 +1,172 @@
package main
import (
"database/sql"
"fmt"
"strconv"
"strings"
"time"
)
const (
stagePhasePreliminary = "preliminary"
stagePhasePrelimTie = "prelim_tiebreak"
stagePhaseFinal = "final"
stagePhaseFinalTie = "final_tiebreak"
)
var allowedStagePhases = map[string]bool{
stagePhasePreliminary: true,
stagePhasePrelimTie: true,
stagePhaseFinal: true,
stagePhaseFinalTie: true,
}
func normalizeStagePhase(raw string) (string, bool) {
phase := strings.ToLower(strings.TrimSpace(raw))
if !allowedStagePhases[phase] {
return "", false
}
return phase, true
}
func normalizeStageGroup(phase string, raw string) (string, bool) {
group := strings.TrimSpace(raw)
switch phase {
case stagePhasePreliminary:
if strings.EqualFold(group, positionBoardUnassigned) {
return positionBoardUnassigned, true
}
group = normalizePreliminaryGroupKey(group)
if group == "" {
return "", false
}
return group, true
case stagePhaseFinal:
parsed, err := strconv.Atoi(group)
if err != nil || (parsed != 1 && parsed != 2) {
return "", false
}
return strconv.Itoa(parsed), true
case stagePhasePrelimTie, stagePhaseFinalTie:
parsed, err := strconv.Atoi(group)
if err != nil || parsed <= 0 {
return "", false
}
return strconv.Itoa(parsed), true
default:
return "", false
}
}
func normalizeStageRound(phase string, round int) int {
switch phase {
case stagePhasePreliminary:
if round < 1 {
return 1
}
if round > 3 {
return 3
}
return round
case stagePhaseFinal:
if round < 1 {
return 1
}
if round > 2 {
return 2
}
return round
default:
return 1
}
}
func normalizeTimestamp(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
return parsed.UTC().Format(time.RFC3339)
}
if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil {
return parsed.UTC().Format(time.RFC3339)
}
return trimmed
}
func scanStageSession(scanner interface{ Scan(dest ...any) error }) (*StageSessionState, error) {
var item StageSessionState
var ended sql.NullString
if err := scanner.Scan(&item.ID, &item.Phase, &item.GroupKey, &item.Round, &item.StartedAt, &ended); err != nil {
return nil, err
}
item.Phase = strings.ToLower(strings.TrimSpace(item.Phase))
item.GroupKey = strings.TrimSpace(item.GroupKey)
item.StartedAt = normalizeTimestamp(item.StartedAt)
if ended.Valid {
item.EndedAt = normalizeTimestamp(ended.String)
}
return &item, nil
}
func (a *App) readCurrentStage() (*StageSessionState, error) {
row := a.db.QueryRow(`
SELECT id, phase, group_key, round, started_at, ended_at
FROM stage_sessions
WHERE ended_at IS NULL
ORDER BY started_at DESC, id DESC
LIMIT 1
`)
item, err := scanStageSession(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("read current stage: %w", err)
}
return item, nil
}
func (a *App) readLastStage() (*StageSessionState, error) {
row := a.db.QueryRow(`
SELECT id, phase, group_key, round, started_at, ended_at
FROM stage_sessions
ORDER BY started_at DESC, id DESC
LIMIT 1
`)
item, err := scanStageSession(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("read last stage: %w", err)
}
return item, nil
}
func (a *App) readStageHistory() ([]StageSessionState, error) {
rows, err := a.db.Query(`
SELECT id, phase, group_key, round, started_at, ended_at
FROM stage_sessions
ORDER BY started_at DESC, id DESC
`)
if err != nil {
return nil, fmt.Errorf("query stage history: %w", err)
}
defer rows.Close()
items := make([]StageSessionState, 0)
for rows.Next() {
item, scanErr := scanStageSession(rows)
if scanErr != nil {
return nil, fmt.Errorf("scan stage history: %w", scanErr)
}
items = append(items, *item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate stage history: %w", err)
}
return items, nil
}

728
backend/state.go Normal file
View File

@@ -0,0 +1,728 @@
package main
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
)
const (
positionBoardPreliminary = "preliminary"
positionBoardFinal = "final"
positionBoardPrelimTie = "prelim_tiebreak"
positionBoardFinalTie = "final_tiebreak"
positionBoardUnassigned = "UNASSIGNED"
)
var allowedPositionBoards = map[string]bool{
positionBoardPreliminary: true,
positionBoardFinal: true,
positionBoardPrelimTie: true,
positionBoardFinalTie: true,
}
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)
positionBoards, err := a.readPositionBoards(players, derived)
if err != nil {
return StateResponse{}, err
}
currentStage, err := a.readCurrentStage()
if err != nil {
return StateResponse{}, err
}
lastStage, err := a.readLastStage()
if err != nil {
return StateResponse{}, err
}
response := StateResponse{
Competition: CompetitionMeta{
TitleAr: "بطولة دويتوايلر للرماية",
TitleEn: "Datwyler Shooting Event",
},
Players: players,
Scores: scoreMapToJSON(scoreMap),
PositionBoards: positionBoardMapToJSON(positionBoards),
Settings: settings,
Derived: derived,
CurrentStage: currentStage,
LastStage: lastStage,
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 positionBoardMapToJSON(positionMap map[string]map[int]PositionSlot) map[string]map[string]PositionSlot {
out := map[string]map[string]PositionSlot{}
for board := range allowedPositionBoards {
out[board] = map[string]PositionSlot{}
}
for board, boardMap := range positionMap {
if _, ok := out[board]; !ok {
out[board] = map[string]PositionSlot{}
}
for playerID, slot := range boardMap {
out[board][strconv.Itoa(playerID)] = slot
}
}
return out
}
func (a *App) readPositionBoards(players []Player, derived DerivedState) (map[string]map[int]PositionSlot, error) {
defaults := defaultPositionBoards(players, derived)
merged := map[string]map[int]PositionSlot{}
for board, boardMap := range defaults {
merged[board] = map[int]PositionSlot{}
for playerID, slot := range boardMap {
merged[board][playerID] = slot
}
}
rows, err := a.db.Query(`SELECT board, player_id, group_key, position FROM player_positions`)
if err != nil {
return nil, fmt.Errorf("query player positions: %w", err)
}
defer rows.Close()
for rows.Next() {
var boardRaw string
var playerID int
var groupKey string
var position int
if err := rows.Scan(&boardRaw, &playerID, &groupKey, &position); err != nil {
return nil, fmt.Errorf("scan player position: %w", err)
}
board, ok := normalizePositionBoard(boardRaw)
if !ok {
continue
}
boardMap := merged[board]
if boardMap == nil {
continue
}
defaultSlot, exists := boardMap[playerID]
if !exists {
continue
}
pos := normalizePositionValue(position, defaultSlot.Position)
group := normalizePositionGroupKey(board, groupKey, defaultSlot.GroupKey)
boardMap[playerID] = PositionSlot{
GroupKey: group,
Position: pos,
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate player positions: %w", err)
}
return merged, nil
}
func defaultPositionBoards(players []Player, derived DerivedState) map[string]map[int]PositionSlot {
out := map[string]map[int]PositionSlot{
positionBoardPreliminary: {},
positionBoardFinal: {},
positionBoardPrelimTie: {},
positionBoardFinalTie: {},
}
preRows := make([]Player, 0, len(players))
preRows = append(preRows, players...)
sort.SliceStable(preRows, func(i, j int) bool {
ga := normalizePreliminaryGroupKey(preRows[i].GroupCode)
gb := normalizePreliminaryGroupKey(preRows[j].GroupCode)
if ga != gb {
return ga < gb
}
return preRows[i].ID < preRows[j].ID
})
preGroupCounts := map[string]int{}
for _, player := range preRows {
group := normalizePreliminaryGroupKey(player.GroupCode)
preGroupCounts[group]++
out[positionBoardPreliminary][player.ID] = PositionSlot{
GroupKey: group,
Position: preGroupCounts[group],
}
}
finalGroupCounts := map[string]int{}
for _, row := range derived.FinalGroups.Group1 {
group := "1"
finalGroupCounts[group]++
out[positionBoardFinal][row.PlayerID] = PositionSlot{GroupKey: group, Position: finalGroupCounts[group]}
}
for _, row := range derived.FinalGroups.Group2 {
group := "2"
finalGroupCounts[group]++
out[positionBoardFinal][row.PlayerID] = PositionSlot{GroupKey: group, Position: finalGroupCounts[group]}
}
preRowsByID := map[int]RankingRow{}
for _, row := range derived.PreliminaryRanking.Rows {
preRowsByID[row.PlayerID] = row
}
preTieRows := make([]RankingRow, 0, len(derived.PreliminaryRanking.TieBreak.PlayerIDs))
for _, id := range derived.PreliminaryRanking.TieBreak.PlayerIDs {
row, ok := preRowsByID[id]
if !ok {
continue
}
preTieRows = append(preTieRows, row)
}
sort.SliceStable(preTieRows, func(i, j int) bool {
if preTieRows[i].TieBreak != preTieRows[j].TieBreak {
return preTieRows[i].TieBreak > preTieRows[j].TieBreak
}
if preTieRows[i].Score != preTieRows[j].Score {
return preTieRows[i].Score > preTieRows[j].Score
}
return preTieRows[i].PlayerID < preTieRows[j].PlayerID
})
for idx, row := range preTieRows {
groupKey := strconv.Itoa((idx / 6) + 1)
position := (idx % 6) + 1
out[positionBoardPrelimTie][row.PlayerID] = PositionSlot{GroupKey: groupKey, Position: position}
}
finalRowsByID := map[int]RankingRow{}
for _, row := range derived.FinalRanking.Rows {
finalRowsByID[row.PlayerID] = row
}
finalTieRows := make([]RankingRow, 0, len(derived.FinalRanking.TieBreak.PlayerIDs))
for _, id := range derived.FinalRanking.TieBreak.PlayerIDs {
row, ok := finalRowsByID[id]
if !ok {
continue
}
finalTieRows = append(finalTieRows, row)
}
sort.SliceStable(finalTieRows, func(i, j int) bool {
if finalTieRows[i].TieBreak != finalTieRows[j].TieBreak {
return finalTieRows[i].TieBreak > finalTieRows[j].TieBreak
}
if finalTieRows[i].Score != finalTieRows[j].Score {
return finalTieRows[i].Score > finalTieRows[j].Score
}
return finalTieRows[i].PlayerID < finalTieRows[j].PlayerID
})
for idx, row := range finalTieRows {
groupKey := strconv.Itoa((idx / 6) + 1)
position := (idx % 6) + 1
out[positionBoardFinalTie][row.PlayerID] = PositionSlot{GroupKey: groupKey, Position: position}
}
return out
}
func normalizePositionBoard(board string) (string, bool) {
normalized := strings.ToLower(strings.TrimSpace(board))
if !allowedPositionBoards[normalized] {
return "", false
}
return normalized, true
}
func normalizePreliminaryGroupKey(groupCode string) string {
normalized := strings.ToUpper(strings.TrimSpace(groupCode))
if normalized == "" {
return positionBoardUnassigned
}
return normalized
}
func normalizePositionGroupKey(board, groupKey, fallback string) string {
if board == positionBoardPreliminary {
normalized := normalizePreliminaryGroupKey(groupKey)
if normalized == "" {
return fallback
}
return normalized
}
normalized := strings.TrimSpace(groupKey)
if normalized == "" {
return fallback
}
return normalized
}
func normalizePositionValue(position int, fallback int) int {
if position > 0 {
return position
}
if fallback > 0 {
return fallback
}
return 1
}
func (a *App) ensureScoreRows(players []Player) error {
if len(players) == 0 {
return nil
}
tx, err := a.db.Begin()
if err != nil {
return fmt.Errorf("begin score row ensure tx: %w", err)
}
defer tx.Rollback()
for _, p := range players {
for _, stage := range scoreStages {
if _, err := tx.Exec(`INSERT OR IGNORE INTO scores(stage, player_id, score) VALUES(?, ?, 0)`, stage, p.ID); err != nil {
return fmt.Errorf("ensure score row (%s,%d): %w", stage, p.ID, err)
}
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit score row ensure tx: %w", err)
}
return nil
}
func (a *App) readScores(players []Player) (map[string]map[int]int, error) {
scores := map[string]map[int]int{}
for _, stage := range scoreStages {
scores[stage] = map[int]int{}
}
for _, p := range players {
for _, stage := range scoreStages {
scores[stage][p.ID] = 0
}
}
rows, err := a.db.Query(`SELECT stage, player_id, score FROM scores`)
if err != nil {
return nil, fmt.Errorf("query scores: %w", err)
}
defer rows.Close()
for rows.Next() {
var stage string
var playerID int
var score int
if err := rows.Scan(&stage, &playerID, &score); err != nil {
return nil, fmt.Errorf("scan score: %w", err)
}
stage = strings.ToLower(stage)
if _, ok := scores[stage]; !ok {
continue
}
scores[stage][playerID] = score
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate scores: %w", err)
}
return scores, nil
}
func scoreMapToJSON(scoreMap map[string]map[int]int) map[string]map[string]int {
out := map[string]map[string]int{}
for stage, stageMap := range scoreMap {
out[stage] = map[string]int{}
for playerID, value := range stageMap {
out[stage][strconv.Itoa(playerID)] = value
}
}
return out
}
func computeDerived(players []Player, scores map[string]map[int]int) DerivedState {
playerByID := map[int]Player{}
for _, p := range players {
playerByID[p.ID] = p
}
preRows := []RankingRow{}
for _, p := range players {
preRows = append(preRows, RankingRow{
PlayerID: p.ID,
NameAr: p.NameAr,
NameEn: p.NameEn,
GroupCode: p.GroupCode,
ImageData: p.ImageData,
Score: scoreTotal(scores, p.ID, preliminaryRoundStages),
TieBreak: scores["prelim_tiebreak"][p.ID],
})
}
sort.SliceStable(preRows, func(i, j int) bool {
if preRows[i].Score != preRows[j].Score {
return preRows[i].Score > preRows[j].Score
}
return preRows[i].PlayerID < preRows[j].PlayerID
})
assignDenseRankByScore(preRows)
preTie := TieBreakInfo{Required: false, Resolved: true, Slots: 0, PlayerIDs: []int{}}
finalists := []RankingRow{}
if len(preRows) <= 12 {
for i := range preRows {
row := preRows[i]
row.Seed = i + 1
finalists = append(finalists, row)
}
} else {
cutoff := preRows[11].Score
above := []RankingRow{}
atCutoff := []RankingRow{}
for _, row := range preRows {
if row.Score > cutoff {
above = append(above, row)
} else if row.Score == cutoff {
atCutoff = append(atCutoff, row)
}
}
slots := 12 - len(above)
if slots < 0 {
slots = 0
}
if len(atCutoff) <= slots {
finalists = append(finalists, above...)
finalists = append(finalists, atCutoff...)
} else {
preTie.Required = true
preTie.Slots = slots
preTie.Resolved = true
for _, row := range atCutoff {
preTie.PlayerIDs = append(preTie.PlayerIDs, row.PlayerID)
}
sort.Ints(preTie.PlayerIDs)
sort.SliceStable(atCutoff, func(i, j int) bool {
if atCutoff[i].TieBreak != atCutoff[j].TieBreak {
return atCutoff[i].TieBreak > atCutoff[j].TieBreak
}
return atCutoff[i].PlayerID < atCutoff[j].PlayerID
})
if slots > 0 {
boundary := atCutoff[slots-1].TieBreak
greater := 0
equal := 0
for _, row := range atCutoff {
if row.TieBreak > boundary {
greater++
} else if row.TieBreak == boundary {
equal++
}
}
if greater < slots && greater+equal > slots {
preTie.Resolved = false
}
}
finalists = append(finalists, above...)
if slots > len(atCutoff) {
slots = len(atCutoff)
}
finalists = append(finalists, atCutoff[:slots]...)
}
sort.SliceStable(finalists, func(i, j int) bool {
if finalists[i].Score != finalists[j].Score {
return finalists[i].Score > finalists[j].Score
}
if preTie.Required {
if finalists[i].TieBreak != finalists[j].TieBreak {
return finalists[i].TieBreak > finalists[j].TieBreak
}
}
return finalists[i].PlayerID < finalists[j].PlayerID
})
assignDenseRankBy(finalists, func(a, b RankingRow) bool {
if a.Score != b.Score {
return false
}
if preTie.Required {
return a.TieBreak == b.TieBreak
}
return true
})
}
for i := range finalists {
finalists[i].Seed = i + 1
if i < 6 {
finalists[i].FinalGroup = 1
} else {
finalists[i].FinalGroup = 2
}
}
finalGroup1 := []RankingRow{}
finalGroup2 := []RankingRow{}
for _, row := range finalists {
if row.FinalGroup == 1 {
finalGroup1 = append(finalGroup1, row)
} else {
finalGroup2 = append(finalGroup2, row)
}
}
finalRows := []RankingRow{}
for _, finalist := range finalists {
p := playerByID[finalist.PlayerID]
finalRows = append(finalRows, RankingRow{
PlayerID: finalist.PlayerID,
NameAr: p.NameAr,
NameEn: p.NameEn,
GroupCode: p.GroupCode,
ImageData: p.ImageData,
Score: scoreTotal(scores, finalist.PlayerID, finalRoundStages),
TieBreak: scores["final_tiebreak"][finalist.PlayerID],
Seed: finalist.Seed,
FinalGroup: finalist.FinalGroup,
})
}
sort.SliceStable(finalRows, func(i, j int) bool {
if finalRows[i].Score != finalRows[j].Score {
return finalRows[i].Score > finalRows[j].Score
}
return finalRows[i].Seed < finalRows[j].Seed
})
finalTie := TieBreakInfo{Required: false, Resolved: true, Slots: 0, PlayerIDs: []int{}}
tiedTop := map[int]bool{}
if len(finalRows) > 0 {
i := 0
for i < len(finalRows) {
j := i + 1
for j < len(finalRows) && finalRows[j].Score == finalRows[i].Score {
j++
}
if j-i > 1 && finalRows[i].Score > 0 {
startPos := i + 1
endPos := j
if startPos <= 3 || endPos <= 3 || (startPos < 3 && endPos > 3) {
for k := i; k < j; k++ {
tiedTop[finalRows[k].PlayerID] = true
}
}
}
i = j
}
if len(tiedTop) > 0 {
finalTie.Required = true
for id := range tiedTop {
finalTie.PlayerIDs = append(finalTie.PlayerIDs, id)
}
sort.Ints(finalTie.PlayerIDs)
sort.SliceStable(finalRows, func(i, j int) bool {
if finalRows[i].Score != finalRows[j].Score {
return finalRows[i].Score > finalRows[j].Score
}
iti := tiedTop[finalRows[i].PlayerID]
itj := tiedTop[finalRows[j].PlayerID]
if iti && itj && finalRows[i].TieBreak != finalRows[j].TieBreak {
return finalRows[i].TieBreak > finalRows[j].TieBreak
}
return finalRows[i].Seed < finalRows[j].Seed
})
}
}
if finalTie.Required {
assignDenseRankBy(finalRows, func(a, b RankingRow) bool {
if a.Score != b.Score {
return false
}
if tiedTop[a.PlayerID] && tiedTop[b.PlayerID] {
return a.TieBreak == b.TieBreak
}
return true
})
if len(finalRows) >= 3 {
third := finalRows[2]
greater := 0
equal := 0
for _, row := range finalRows {
if row.Score > third.Score {
greater++
continue
}
if row.Score == third.Score {
itied := tiedTop[row.PlayerID]
ttied := tiedTop[third.PlayerID]
if itied && ttied {
if row.TieBreak > third.TieBreak {
greater++
} else if row.TieBreak == third.TieBreak {
equal++
}
} else {
equal++
}
}
}
if greater < 3 && greater+equal > 3 {
finalTie.Resolved = false
}
}
} else {
assignDenseRankByScore(finalRows)
}
podium := []RankingRow{}
for i := 0; i < len(finalRows) && i < 3; i++ {
podium = append(podium, finalRows[i])
}
return DerivedState{
PreliminaryRanking: RankingBundle{Rows: preRows, TieBreak: preTie, Unresolved: preTie.Required && !preTie.Resolved},
Finalists: finalists,
FinalGroups: FinalGroups{Group1: finalGroup1, Group2: finalGroup2},
FinalRanking: RankingBundle{Rows: finalRows, TieBreak: finalTie, Unresolved: finalTie.Required && !finalTie.Resolved},
Podium: podium,
}
}
func scoreTotal(scores map[string]map[int]int, playerID int, stages []string) int {
total := 0
for _, stage := range stages {
stageMap, ok := scores[stage]
if !ok {
continue
}
total += stageMap[playerID]
}
return total
}
func assignDenseRankByScore(rows []RankingRow) {
assignDenseRankBy(rows, func(a, b RankingRow) bool {
return a.Score == b.Score
})
}
func assignDenseRankBy(rows []RankingRow, isEqual func(a, b RankingRow) bool) {
if len(rows) == 0 {
return
}
currentRank := 1
rows[0].Rank = currentRank
for i := 1; i < len(rows); i++ {
if !isEqual(rows[i], rows[i-1]) {
currentRank = i + 1
}
rows[i].Rank = currentRank
}
}

32
docker-compose.yml Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,747 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
vue:
specifier: ^3.5.22
version: 3.5.31
devDependencies:
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.4(vite@5.4.21)(vue@3.5.31)
vite:
specifier: ^5.4.19
version: 5.4.21
packages:
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.2':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.21.5':
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.21.5':
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.21.5':
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.21.5':
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.21.5':
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.21.5':
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.21.5':
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.21.5':
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.21.5':
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.21.5':
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.21.5':
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.21.5':
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.21.5':
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.21.5':
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.21.5':
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.21.5':
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-x64@0.21.5':
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-x64@0.21.5':
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.21.5':
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.21.5':
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.21.5':
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.21.5':
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@rollup/rollup-android-arm-eabi@4.60.1':
resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.60.1':
resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.60.1':
resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.60.1':
resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.60.1':
resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.60.1':
resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.60.1':
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.60.1':
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.60.1':
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loong64-gnu@4.60.1':
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-loong64-musl@4.60.1':
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-ppc64-musl@4.60.1':
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.60.1':
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.60.1':
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.60.1':
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.60.1':
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
cpu: [x64]
os: [linux]
'@rollup/rollup-openbsd-x64@4.60.1':
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.60.1':
resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.60.1':
resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.60.1':
resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.60.1':
resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.60.1':
resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@vitejs/plugin-vue@5.2.4':
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
'@vue/compiler-core@3.5.31':
resolution: {integrity: sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==}
'@vue/compiler-dom@3.5.31':
resolution: {integrity: sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==}
'@vue/compiler-sfc@3.5.31':
resolution: {integrity: sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==}
'@vue/compiler-ssr@3.5.31':
resolution: {integrity: sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==}
'@vue/reactivity@3.5.31':
resolution: {integrity: sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==}
'@vue/runtime-core@3.5.31':
resolution: {integrity: sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==}
'@vue/runtime-dom@3.5.31':
resolution: {integrity: sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==}
'@vue/server-renderer@3.5.31':
resolution: {integrity: sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==}
peerDependencies:
vue: 3.5.31
'@vue/shared@3.5.31':
resolution: {integrity: sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
hasBin: true
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
rollup@4.60.1:
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
vite@5.4.21:
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || >=20.0.0
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
vue@3.5.31:
resolution: {integrity: sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
snapshots:
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
'@babel/parser@7.29.2':
dependencies:
'@babel/types': 7.29.0
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@esbuild/aix-ppc64@0.21.5':
optional: true
'@esbuild/android-arm64@0.21.5':
optional: true
'@esbuild/android-arm@0.21.5':
optional: true
'@esbuild/android-x64@0.21.5':
optional: true
'@esbuild/darwin-arm64@0.21.5':
optional: true
'@esbuild/darwin-x64@0.21.5':
optional: true
'@esbuild/freebsd-arm64@0.21.5':
optional: true
'@esbuild/freebsd-x64@0.21.5':
optional: true
'@esbuild/linux-arm64@0.21.5':
optional: true
'@esbuild/linux-arm@0.21.5':
optional: true
'@esbuild/linux-ia32@0.21.5':
optional: true
'@esbuild/linux-loong64@0.21.5':
optional: true
'@esbuild/linux-mips64el@0.21.5':
optional: true
'@esbuild/linux-ppc64@0.21.5':
optional: true
'@esbuild/linux-riscv64@0.21.5':
optional: true
'@esbuild/linux-s390x@0.21.5':
optional: true
'@esbuild/linux-x64@0.21.5':
optional: true
'@esbuild/netbsd-x64@0.21.5':
optional: true
'@esbuild/openbsd-x64@0.21.5':
optional: true
'@esbuild/sunos-x64@0.21.5':
optional: true
'@esbuild/win32-arm64@0.21.5':
optional: true
'@esbuild/win32-ia32@0.21.5':
optional: true
'@esbuild/win32-x64@0.21.5':
optional: true
'@jridgewell/sourcemap-codec@1.5.5': {}
'@rollup/rollup-android-arm-eabi@4.60.1':
optional: true
'@rollup/rollup-android-arm64@4.60.1':
optional: true
'@rollup/rollup-darwin-arm64@4.60.1':
optional: true
'@rollup/rollup-darwin-x64@4.60.1':
optional: true
'@rollup/rollup-freebsd-arm64@4.60.1':
optional: true
'@rollup/rollup-freebsd-x64@4.60.1':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.60.1':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-arm64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-loong64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.60.1':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-x64-gnu@4.60.1':
optional: true
'@rollup/rollup-linux-x64-musl@4.60.1':
optional: true
'@rollup/rollup-openbsd-x64@4.60.1':
optional: true
'@rollup/rollup-openharmony-arm64@4.60.1':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.60.1':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.60.1':
optional: true
'@rollup/rollup-win32-x64-gnu@4.60.1':
optional: true
'@rollup/rollup-win32-x64-msvc@4.60.1':
optional: true
'@types/estree@1.0.8': {}
'@vitejs/plugin-vue@5.2.4(vite@5.4.21)(vue@3.5.31)':
dependencies:
vite: 5.4.21
vue: 3.5.31
'@vue/compiler-core@3.5.31':
dependencies:
'@babel/parser': 7.29.2
'@vue/shared': 3.5.31
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.31':
dependencies:
'@vue/compiler-core': 3.5.31
'@vue/shared': 3.5.31
'@vue/compiler-sfc@3.5.31':
dependencies:
'@babel/parser': 7.29.2
'@vue/compiler-core': 3.5.31
'@vue/compiler-dom': 3.5.31
'@vue/compiler-ssr': 3.5.31
'@vue/shared': 3.5.31
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.8
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.31':
dependencies:
'@vue/compiler-dom': 3.5.31
'@vue/shared': 3.5.31
'@vue/reactivity@3.5.31':
dependencies:
'@vue/shared': 3.5.31
'@vue/runtime-core@3.5.31':
dependencies:
'@vue/reactivity': 3.5.31
'@vue/shared': 3.5.31
'@vue/runtime-dom@3.5.31':
dependencies:
'@vue/reactivity': 3.5.31
'@vue/runtime-core': 3.5.31
'@vue/shared': 3.5.31
csstype: 3.2.3
'@vue/server-renderer@3.5.31(vue@3.5.31)':
dependencies:
'@vue/compiler-ssr': 3.5.31
'@vue/shared': 3.5.31
vue: 3.5.31
'@vue/shared@3.5.31': {}
csstype@3.2.3: {}
entities@7.0.1: {}
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
'@esbuild/android-arm': 0.21.5
'@esbuild/android-arm64': 0.21.5
'@esbuild/android-x64': 0.21.5
'@esbuild/darwin-arm64': 0.21.5
'@esbuild/darwin-x64': 0.21.5
'@esbuild/freebsd-arm64': 0.21.5
'@esbuild/freebsd-x64': 0.21.5
'@esbuild/linux-arm': 0.21.5
'@esbuild/linux-arm64': 0.21.5
'@esbuild/linux-ia32': 0.21.5
'@esbuild/linux-loong64': 0.21.5
'@esbuild/linux-mips64el': 0.21.5
'@esbuild/linux-ppc64': 0.21.5
'@esbuild/linux-riscv64': 0.21.5
'@esbuild/linux-s390x': 0.21.5
'@esbuild/linux-x64': 0.21.5
'@esbuild/netbsd-x64': 0.21.5
'@esbuild/openbsd-x64': 0.21.5
'@esbuild/sunos-x64': 0.21.5
'@esbuild/win32-arm64': 0.21.5
'@esbuild/win32-ia32': 0.21.5
'@esbuild/win32-x64': 0.21.5
estree-walker@2.0.2: {}
fsevents@2.3.3:
optional: true
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
nanoid@3.3.11: {}
picocolors@1.1.1: {}
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
rollup@4.60.1:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.60.1
'@rollup/rollup-android-arm64': 4.60.1
'@rollup/rollup-darwin-arm64': 4.60.1
'@rollup/rollup-darwin-x64': 4.60.1
'@rollup/rollup-freebsd-arm64': 4.60.1
'@rollup/rollup-freebsd-x64': 4.60.1
'@rollup/rollup-linux-arm-gnueabihf': 4.60.1
'@rollup/rollup-linux-arm-musleabihf': 4.60.1
'@rollup/rollup-linux-arm64-gnu': 4.60.1
'@rollup/rollup-linux-arm64-musl': 4.60.1
'@rollup/rollup-linux-loong64-gnu': 4.60.1
'@rollup/rollup-linux-loong64-musl': 4.60.1
'@rollup/rollup-linux-ppc64-gnu': 4.60.1
'@rollup/rollup-linux-ppc64-musl': 4.60.1
'@rollup/rollup-linux-riscv64-gnu': 4.60.1
'@rollup/rollup-linux-riscv64-musl': 4.60.1
'@rollup/rollup-linux-s390x-gnu': 4.60.1
'@rollup/rollup-linux-x64-gnu': 4.60.1
'@rollup/rollup-linux-x64-musl': 4.60.1
'@rollup/rollup-openbsd-x64': 4.60.1
'@rollup/rollup-openharmony-arm64': 4.60.1
'@rollup/rollup-win32-arm64-msvc': 4.60.1
'@rollup/rollup-win32-ia32-msvc': 4.60.1
'@rollup/rollup-win32-x64-gnu': 4.60.1
'@rollup/rollup-win32-x64-msvc': 4.60.1
fsevents: 2.3.3
source-map-js@1.2.1: {}
vite@5.4.21:
dependencies:
esbuild: 0.21.5
postcss: 8.5.8
rollup: 4.60.1
optionalDependencies:
fsevents: 2.3.3
vue@3.5.31:
dependencies:
'@vue/compiler-dom': 3.5.31
'@vue/compiler-sfc': 3.5.31
'@vue/runtime-dom': 3.5.31
'@vue/server-renderer': 3.5.31(vue@3.5.31)
'@vue/shared': 3.5.31

BIN
frontend/public/arture.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
frontend/public/bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
frontend/public/bg_live.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

63
frontend/public/logo.svg Normal file
View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 583.65 114.56">
<defs>
<style>
.cls-1 {
fill: #008ca8;
}
.cls-2 {
fill: #ec2f23;
}
.cls-3 {
fill: #1b1f40;
}
</style>
</defs>
<g>
<path class="cls-3" d="M545.23,98.25l4.31-7.15c.28-.46.13-1.06-.33-1.33-2.22-1.32-8.46-4.34-10.8-5.19-.64-.23-.88-.38-1.41.22-.65.73-4.89,7.9-4.78,8.41.1.46,8,4.3,11.13,5.65.69.3,1.5.04,1.9-.61Z"/>
<g>
<path class="cls-3" d="M392.94,85.7c5.81.93,14.01.79,19.19-2.28,3.16-1.87,4.61-5.35,5.87-8.63l1.32,4.87c3.19,8.82,8.21,15.31,18.58,12.74,10.58-2.63,13.61-18.14,12.73-27.42-.65-6.87-2.57-13.59-3.36-20.44l1.61-.11,3.36-9.01c.02-.45-.27-.62-.56-.87-2.28-1.94-6.47-3.24-8.09-6.06-1.38-2.41-.88-4.59-.39-7.13-.32-.06-.53.03-.81.16-1.84.87-5.37,7.2-5.99,9.22-.44,1.44-.52,2.45,0,3.88.32.91,1.51,2.23,1.64,2.78.19.82-.16,1.4-.15,2.08.29,10.93,4.43,22.34,6.47,33,.24,1.27,1.13,5.76.62,6.65-.87,1.53-4.98,2.6-6.65,2.73-13.69,1.08-17.23-22.88-17.26-24.5-.02-1.63,3.27-6.22,4.17-8.05.29-.6,1.45-2.94,1.31-3.43l-.52-.21-5.3,7.35c-.93-3.2-1.96-6.43-2.15-9.79-.69-.95-1.17.03-1.63.61-2.69,3.35-3.93,5.91-3.64,10.33.04.65.56,1.64.19,2.14-7.63,4.11-16.83,6.5-21.56,14.35-1.4,2.33-4.47,9.4-4.06,11.96.39,2.45,3.05,2.76,5.05,3.08ZM398.75,71.81c5.39-3.26,11.6-5.51,16.24-9.94.49,2.58,1.29,5.14,1.62,7.75.07.57.35,1.65,0,2.1-.33.43-3.52,2.14-4.2,2.44-7.89,3.39-16.94.64-17.15.36-.11-.47,2.94-2.38,3.48-2.71Z"/>
<path class="cls-3" d="M384.64,43.49l10.08,4.98c.43.21.94.06,1.19-.35l3.86-6.41c3.08.93,5.9,2.66,8.65,4.33.21.12.41.05.58-.09l5.38-7.11c.28-.37.19-.89-.2-1.14-1.87-1.21-7.19-4.06-9.28-4.89-1.71-.67-1.63-.51-2.65.83-1.28,1.68-2.21,3.59-3.39,5.34-.64.19-6.86-3.71-9.28-4.05-.37-.05-.74.13-.94.45l-4.34,6.88c-.27.43-.11,1,.35,1.23Z"/>
<path class="cls-1" d="M412.38,30.98c5.42-3.61,12.17-5.45,18.12-8.01,1.55-.67,4.04-1.64,5.19-2.83.46-.48,2.17-3.45,2.03-3.98l-.35-.22c-1.06.57-2.11,1.19-3.21,1.69-5.69,2.56-12.2,4.4-17.73,7.18-3.1,1.56-5.78,3.95-6.06,7.62.12.14,1.76-1.28,2-1.44Z"/>
<path class="cls-1" d="M544.19,36.97c.53-.43,2.67-2.92,2.87-2.97,1.09-.27,2.33,2.56,3.41.76,1.27-2.13.29-4.12-1.85-5.02,3.63-5.13-1.82-8.77-5.51-4-2.26,2.93-2.19,6,1.66,7.24.3.4-5.23,3.9-5.71,4.19-2.66,1.58-5.51,2.96-8.26,4.39-.68.35-2.27.92-2.73,1.27-.28.21-.32.27-.27.64,8.41-1.44,14.63-5.05,16.39-6.5ZM544.77,28.23c-.28-1.72,3.07-1.19,2.34.92-.62.13-2.23-.22-2.34-.92Z"/>
<path class="cls-1" d="M487.55,49.01c.45-.37,2.28-2.5,2.46-2.54.93-.23,2,2.19,2.92.65,1.09-1.82.25-3.53-1.58-4.3,3.11-4.39-1.56-7.51-4.72-3.42-1.93,2.51-1.88,5.14,1.42,6.2.26.35-4.47,3.34-4.89,3.58-2.28,1.35-4.72,2.53-7.07,3.75-.58.3-1.94.78-2.33,1.08-.24.18-.28.23-.23.55,7.19-1.23,12.52-4.32,14.03-5.56ZM488.04,41.53c-.24-1.47,2.63-1.02,2,.79-.53.12-1.91-.19-2-.79Z"/>
<path class="cls-2" d="M475.87,43.99c.16-.42,1.45-4.23.57-4.22l-2.7,2.54c-.95.9-2.32,1.27-3.56.88-2.75-.86-3.43-4.62-3.2-7.68.37-4.86,3.22-9.04,4.99-13.43-.05-.06-.59.33-.71.44-4.34,4.2-8.86,15.7-7.73,21.64,1.59,8.37,9.66,6.77,12.34-.16Z"/>
<path class="cls-3" d="M560.15,59.59c-.48-6.52-4.32-9.98-8.51-15.45-.96-.25-1.27.94-1.6,1.63-.78,1.57-2.91,6.79-2.81,8.35.16,2.1,4.24,6.97,5.41,9.28.31.61,1.54,3.25,1.24,3.73-.45.71-7.04,2.47-8.23,2.74-4.77,1.09-9.7,1.58-14.5,2.56-.26-.23,6.7-5.19,9.46-13.62,1.79-5.5-6.64-10.43-11.05-11.41-7.4-1.67-15.05,3.33-20.35,7.96l-4.23,4.17c.47-1.51,1.23-2.9,1.67-4.43,2.64-9.01-.34-20.1-.44-20.34.63-.09,1.27.71,1.67-.01.87-2.38,1.4-4.98,2.45-7.3.22-.5,1.13-1.55,1-1.95-5.81-3.68-12.82-6.43-10.61-13.11-.27-1.24-2.06,1.48-2.25,1.79-1.2,1.99-3.4,6.92-3.4,9.17,0,3.07,2.12,4.11,2.46,5.87.12.63-.18,1.95-.16,2.8.14,4.15,2.12,9.94,2.96,14.21,1.93,9.78,2.55,16.11-4.81,23.74-.72.75-9.19,3.71-10.2,2.78-2.26-6.39-10.51-19.18-18.41-12.73-3.52,2.87-6.25,8.73-7.48,13.04-2.5,8.83,4.32,10.97,11.65,11.28,2.5.12,4.52-.38,6.9-.52,1.11-.08,1.99-.09,1.23,1.22-.92,1.58-15.56,16.28-29.89,15.6-2.72-.13-10.67-1.83-14.21-2.81-.34-.09-.56.36-.27.57.75.54,10.15,7.16,16.92,8.41,1.27.23,3.27.21,4.65.21,0,0-1.44.38-1.44.7,0,.1.12.65,1.23.89.83.18,2.45.26,2.45.26,9.13-.07,15.98-8.55,19.69-13.13,2.55-3.14,4.57-6.66,5.78-10.36.14-.41.17-1.55.6-1.7.53-.18,26.49-2.3,34.5-1.32,9.25,1.15,24.69-1.36,32.76-6.16,4.68-2.78,8.59-11.22,8.2-16.59ZM465.63,71.52c-.88-1.54,1.33-3.08,2.77-3.09,1.67-.01,6.42,4.66,6.26,5.29-1.81-.05-8.13-.62-9.03-2.2ZM500.54,70.49c-.08-.31.13-.36.26-.53.7-.92,1.9-1.73,2.74-2.56,4.37-4.31,11.57-10.71,18.14-10.36,3.91.21,7.97,2.72,10.69,5.41.06.28-.23.36-.4.47-.84.56-2.8,1.31-3.79,1.7-8.75,3.39-18.36,4.8-27.64,5.89Z"/>
</g>
</g>
<g>
<path class="cls-2" d="M217.93,56.12c2.78-1.56,3.83-5.94,4.41-8.84l-.31-.22c-1.28,2.08-4,4.11-6.31,2.14-2.91-2.49-1.56-7.07-.57-10.19.99-3.12,2.59-5.94,3.94-8.89,0,0-12.84,14.54-7.4,24.01,1.32,2.29,3.82,3.34,6.23,1.98Z"/>
<path class="cls-1" d="M339.55,40.67c-.21.38-1.07,2.03-1.03,2.32.02.13.31.35.47.12,5.9-4.13,13.01-5.94,19.63-8.59,4.02-1.61,7.78-2.67,10.3-6.45.73-1.09,1.59-2.51,1.51-3.86l-2.7,1.79c-8.01,5.05-23.37,5.98-28.18,14.67Z"/>
<path class="cls-2" d="M251.93,48.45c2.1,1.09,3.59-.9,4.9-2.21,2.22,1.02,4.11.54,5.3-1.62,1.28-2.33.53-4.16-.14-6.51-.85-.84-1.13,1.52-1.14,2.06,0,1.09.59,2.4-.66,3.18-1.34.83-2.38-.6-2.52-1.85-.07-.63,0-1.46,0-2.12,0-.74-.76.64-.82.77-.47,1.06-.45,1.75-.67,2.78-.59,2.71-3.83,3.62-4,.25-.02-.42.22-1.76.17-1.92-.3-1.14-1.3,1.41-1.38,1.65-.51,1.6-.84,4.62.95,5.55Z"/>
<g>
<path class="cls-3" d="M371.43,67.19c-1.92-5.4-10.44-12.79-16.07-15.06-2.28-.92-4.29-.92-5.95,0-2.69,1.49-3.83,5.03-4.31,7.74-.25,1.55-.59,4.2,0,4.62l.2.14.24-.06c.68-.17,1.25-.53,1.8-.88.74-.46,1.37-.86,2.21-.86,2.34,0,8.55,5.01,11.36,7.7,1.77,1.69,3.56,3.77,5.08,5.88-.43.22-1.49.65-4.1,1.45l-.11.03c-3.7,1.13-7.91,2.17-11.53,2.85-.22.04-.46.09-.72.14-2.51.5-6.71,1.33-7.93-.74-.15-.26-.25-.58-.35-.91-.16-.55-.35-1.17-.87-1.63l-.15-.14h-.2c-.48,0-.73.14-1.62,3.9-1.22,5.13-.98,8.35.76,10.12,1.07,1.08,2.69,1.61,4.97,1.61,1.81,0,4.05-.31,7.26-1.01,3.87-.85,15.21-3.89,17.47-6.22.98-1.01,1.5-2.63,1.67-3.28,1.25-4.65,2.41-11.09.88-15.39Z"/>
<path class="cls-3" d="M334.01,41.61l-.26.06.26-.07c-1.94-8.37-3.96-17.03-3.69-24.32,0-.1.05-.23.1-.36.13-.34.33-.85-.03-1.44l-.12-.2-.23-.05c-.98-.2-1.59.44-2.05.91l-.1.11c-6.98,7.12-6.41,14.05-4.94,22.37.83,4.72,2,9.24,3.13,13.64,2.89,11.24,5.63,21.85,2.6,34.3-.08.31-.46,1.01-.77,1.57-.48.87-.75,1.38-.79,1.73-.05.49.17,1.06.68,1.18.04,0,.08.02.14.02.29,0,.61-.16,2.15-2.17,5.72-7.49,8.5-19.01,7.26-30.08-.61-5.44-2-11.42-3.34-17.2Z"/>
<path class="cls-3" d="M272.08,56.27l.25-.08-.25.08c2.16,6.63,4.2,12.89,3.73,20.06-.06.93-.34,2.15-.62,3.35-.33,1.43-.65,2.78-.63,3.76,0,.32.02.69.5,1.03l.39.27.31-.36c2.71-3.13,4.14-7.58,5.09-11.1,2.62-9.78,1.49-16.82-.91-26.14,0,0-.82-3.09-1.35-4.88-1.28-4.27-3.42-8.98-3.15-12.78.01-.17.09-.5.17-.84.25-1.09.54-2.32-.02-2.95-.13-.15-.43-.4-.96-.35-1.13.11-4.09,4.59-4.11,4.63-3.24,5.18-2.75,8.94-1.68,14.66.76,4.03,2.02,7.9,3.24,11.65Z"/>
<path class="cls-3" d="M287.73,37.28l10.4,5.14c.2.1.42.15.65.15.52,0,1-.27,1.27-.71l3.77-6.26c2.94.96,5.68,2.63,8.33,4.24l.09.06c.4.23.85.16,1.28-.24l5.55-7.34c.23-.3.32-.68.25-1.05-.07-.37-.28-.7-.6-.9-2.01-1.3-7.55-4.26-9.67-5.09-1.85-.73-2.16-.56-3.11.71l-.24.32c-.79,1.04-1.45,2.16-2.12,3.28-.39.65-.79,1.33-1.21,1.97-.54-.19-1.77-.83-2.8-1.36-2.2-1.13-4.93-2.54-6.44-2.76-.6-.09-1.19.19-1.51.71l-4.48,7.1c-.21.34-.27.75-.17,1.14.11.39.38.71.74.89Z"/>
<path class="cls-3" d="M269.89,96.65l.1.06c.41.23.85.15,1.28-.24l5.55-7.34c.23-.3.32-.68.25-1.05s-.28-.7-.6-.9c-2.01-1.3-7.55-4.26-9.67-5.09-1.85-.73-2.16-.56-3.11.71l-.24.32c-.78,1.03-1.45,2.15-2.1,3.25-.39.66-.8,1.35-1.23,2-.54-.19-1.77-.83-2.8-1.36-2.2-1.13-4.93-2.54-6.44-2.76-.6-.09-1.19.19-1.51.71l-4.48,7.1c-.21.34-.27.75-.17,1.14.11.39.38.71.74.89l10.4,5.14c.2.1.42.15.65.15.52,0,1-.27,1.27-.71l3.77-6.26c2.94.96,5.68,2.63,8.33,4.24Z"/>
<path class="cls-2" d="M321.83,98.85h0c-.22.09-.44.3-.81.67-.29.28-.69.67-.94.79-1.38.68-2.96.36-3.76-.76-1.41-1.97-.24-5.48.76-7.94.22-.53.57-1.17.92-1.81.43-.78.87-1.59,1.08-2.25.13-.4.23-.71.03-1.27l-.11-.29-.3-.05c-.64-.12-.95.33-1.11.56-.02.03-.04.06-.07.09-2.28,2.79-6.98,10.67-6.09,15.95.29,1.71,1.13,3.01,2.5,3.85.79.48,1.61.73,2.46.73.73,0,1.47-.19,2.2-.55,2.26-1.13,4.14-3.97,4.37-6.6.02-.2-.02-.78-.4-1.05-.21-.15-.48-.18-.73-.07Z"/>
<path class="cls-2" d="M311.94,13.76h0c-.15.07-.31.21-.58.47-.21.2-.49.47-.67.56-.97.48-2.09.26-2.66-.54-1-1.4-.17-3.88.54-5.62.15-.38.4-.83.65-1.28.3-.55.62-1.12.77-1.59.09-.28.16-.5.02-.9l-.07-.2-.21-.04c-.45-.08-.67.23-.79.4-.02.02-.03.05-.05.07-1.61,1.97-4.94,7.55-4.31,11.29.2,1.21.8,2.13,1.77,2.72.56.34,1.14.52,1.74.52.52,0,1.04-.13,1.56-.39,1.6-.8,2.93-2.81,3.09-4.67.01-.14-.01-.55-.28-.75-.15-.11-.34-.13-.51-.05Z"/>
<path class="cls-3" d="M324.39,61.48c-.46-4.68-6.51-11.83-8.36-13.91-.37-.4-.7-.72-1.03-.98l-.06-.04h-.07c-.37-.07-.58.22-.71.39l-.04.05c-1.1,1.4-3.82,6.09-4.01,7.99-.23,2.25,1.86,4.95,3.53,7.13.52.68,1.02,1.32,1.39,1.88.08.13.19.29.32.47.63.92,2.09,3.06,2.07,3.74,0,.12-.22.8-3.83,2.4-1.45.63-3.84,1.5-4.41,1.5-1.17-4.42-6.15-11.55-11.48-12.92-2.47-.64-4.66.05-6.51,2.05-2.31,2.49-4.2,6.39-5.2,9.22-1.55,4.38-2.29,7.83-.87,10.45.99,1.81,2.94,3.04,5.98,3.76,3.75.89,8.59.71,12.73-.47-.07.19-.18.42-.28.62-1.7,3.37-15.28,16.69-29.4,16.03-2.54-.12-9.74-2.12-13.6-3.2l-.94-.26c-.28-.08-.56.06-.69.32-.13.26-.05.57.18.74.97.7,1.98,1.4,3.02,2.08,3.88,2.52,9.54,5.71,14.49,6.61.93.17,2.79.1,3.98.1-.07.04-.71.2-.76.25-.1.1-.15.21-.14.33,0,.09.06.86,1.47,1.17.82.18,1.64.27,2.45.27,3.53,0,7.57-1.23,10.55-3.2,4.24-2.81,13.94-12.5,15.28-23.12,5.5-2.28,9.66-4.87,12.1-10,.77-1.6,3.23-7.14,2.82-11.46h0ZM298.91,73.94c-.72.07-2.88-.02-3.4-.08-1.46-.18-4.19-.96-4.43-1.88-.11-.4.28-1.4.66-1.7.5-.39,1.37-.39,2.47-.02,1.99.68,4.16,2.4,4.7,3.69Z"/>
<path class="cls-3" d="M267.79,59.46c-1.01-2.05-2.43-4.14-4.23-6.49-.09-.12-.19-.28-.28-.45-.33-.56-.79-1.32-1.46-1.29-.44.01-.8.33-1.1.96-.8,1.61-1.9,4.71-2.49,6.41l-.16.43c-.44,1.25-.95,2.67-.37,4.02.49,1.14,1.32,2.37,2.14,3.57.88,1.28,2.46,4.25,2.46,4.38,0,.01-.07.16-.57.35-.66.24-9.67,2.56-11.43-4.79-1.35-5.65-6.8-34.72-9.24-47.44-.47-2.44-1.6-4.55-2.34-3.71-.8.9-5.73,6.31-6.12,7.53-.09.31-.24.53-.15.89,0,.04.01.08.03.12,3.66,10.92,5.66,23.65,7.3,35.05,0,.01.01.03.01.04.31,2.12.64,4.16,1.04,6.11-.01.45-.2.9-.52,1.37-.55.73-1.52,1.46-2.73,2.05-1.05.51-2.21.86-3.09.96-.88.09-1.78.07-2.71-.08-2.87-.44-5.53-2.01-7.09-4.18-.17-.24-.35-.59-.53-.93-.25-.49-.52-1.01-.83-1.33l-.07-.07c-.51-.59-.94-.48-1.2-.32-.44.23-.72.92-1.05,1.81l-.08.24c-.8,2.04-2.18,6.03-2.33,8.09-.05.71.01,1.41.07,2.09l.04.35.16.15c1.84,1.56,3.33,2.93,4.6,4.51l.2.23c.88,1.08,1.06,1.34,1.09,1.38.2.71-.12,1.8-.92,3.18-.86,1.49-2.17,3.01-3.02,4l-.43.49c-13.12,15.57-24.05,12.43-28.72,11.08-.47-.13-.88-.24-1.22-.33l-.76-.17.12.77c.59,3.86,8.51,7.34,8.86,7.49l.17.05c.81.13,1.64.21,2.43.27-.11.17-.15.39-.13.61l.03.35.32.12c.44.17.9.31,1.38.4.68.13,1.38.2,2.16.2,1.13,0,2.38-.15,3.85-.45,11.24-2.32,18.68-13.46,21.06-23.48.07-.29.15-.77.27-1.46.12-.75.44-2.71.63-2.97.16-.2,1.13-.41,1.6-.52.36-.08.69-.15.95-.23,4.9-1.66,7.94-4.18,9.58-8.01,4.06,10.53,12.35,10.27,13.62,10.3,4.15.08,7.65-2.12,9.62-6.01.48-.96.9-1.86,1.28-2.74,2.61-6.15,2.5-10.42.32-14.91Z"/>
</g>
<path class="cls-1" d="M263.48,30.62c.13-.89-.04-.9-.76-.62-2.19.82-4.7,1.96-6.84,2.95-2.94,1.36-5.86,2.56-6.26,6.24.16.17,2.1-1.17,2.42-1.34,2.38-1.27,4.98-2.14,7.41-3.3,1.63-.78,3.73-1.94,4.02-3.92Z"/>
</g>
<g>
<path class="cls-3" d="M49.47,41.16c-.18.16-.38.22-.61.1-2.87-1.75-5.83-3.55-9.05-4.53l-4.04,6.71c-.26.43-.8.58-1.24.36l-10.55-5.21c-.48-.24-.65-.83-.36-1.28l4.54-7.2c.21-.34.61-.53,1-.48,2.53.36,9.03,4.43,9.7,4.23,1.23-1.82,2.21-3.83,3.55-5.59,1.07-1.41.98-1.58,2.77-.87,2.19.86,7.75,3.85,9.71,5.12.41.26.5.81.21,1.19l-5.63,7.44Z"/>
<g>
<path class="cls-1" d="M115.09,48.87c8.37-4.02,18.12-7.1,26.81-10.56,5.22-2.08,9.22-2.94,11.61-8.6.15-.36.88-2.04.68-2.27l-3.54,1.92c-10.25,4.41-21.04,7.42-31.32,11.77-3.89,1.65-9.13,3.8-11.8,7.15-1.55,1.94-2.79,4.69-3.22,7.14l.36.21c3.1-2.61,6.76-4.99,10.42-6.75Z"/>
<path class="cls-1" d="M191.25,86.23c-8.15,3.26-17.96,5.21-25.73,8.78-3.2,1.47-5.5,3.98-6.5,7.36.26.05.44-.06.66-.14,2.34-.85,4.92-2.43,7.33-3.39,8.27-3.3,17.66-5.33,25.68-9.19,3.11-1.5,6.29-4.34,7.44-7.66-1.15.52-2.2,1.2-3.33,1.76-1.8.88-3.7,1.74-5.56,2.49Z"/>
<path class="cls-2" d="M141.79,56.7c1.9.04,2.21-1.19,3.19-2.26.16-.17,1.2-1.24,1.29-1.26.78-.19,1.62.6,2.3.71,3.33.53,6.08-2.7,6.16-5.81.02-.66-.97-5.78-1.26-5.98-.57-.39-1.14,1.15-1.21,1.56-.25,1.51.44,4.42-.59,5.49-.99,1.02-2.4.7-3.25-.3-.96-1.14-.75-2.62-.85-3.98-.06-.9-.49-.32-.75.15-1.22,2.15-.69,6.93-3.99,7.11-3.16.17-2.45-3.89-2.23-5.91-.54-.54-1.47,1.85-1.61,2.23-1,2.84-1.5,8.16,2.8,8.25Z"/>
<path class="cls-1" d="M69.89,49.89c3.39-1.55,9.76-3.16,11.37-6.74.2-.44.72-1.93-.17-1.8-3.48,1.66-7.18,2.86-10.66,4.51-3.02,1.43-6.09,3.25-6.67,6.86l.3.18c1.98-.92,3.83-2.1,5.82-3.01Z"/>
<path class="cls-2" d="M69.76,42.61c-.02.4.24.41.49.29.49-.23,1.84-4.67,2-5.45,1.41-6.83-1.25-13.04-6.48-17.42-.51-.42-1.85-1.63-2.47-1.35-.85.39-2.28,3.33-2.13,4.27.22,1.36,2.26,2.29,3.2,3.09,4.58,3.93,6.88,7.95,5.96,14.19-.11.77-.53,1.64-.57,2.38Z"/>
<g>
<path class="cls-3" d="M194.12,38.09l3.92-8.92c.17-.48.16-.87-.03-1.19-.1-.17-.35-.31-2.33-1.16-.64-.28-1.25-.54-1.47-.66-4.53-2.45-6.82-5.24-6.81-8.29,0-.25.11-.67.22-1.08.23-.85.42-1.59-.01-1.89-.38-.27-.93.05-1.42.39-2.16,1.53-5.87,8.92-5.5,11.73.12.91.63,1.72,1.08,2.44.32.5.61.98.68,1.35.13.69.12,1.71.12,2.7,0,.85-.01,1.73.07,2.45.14,1.27.41,2.65.66,3.99.1.52.2,1.03.29,1.53.73,4.03,1.66,8.09,2.57,12.02,1.23,5.34,2.51,10.86,3.27,16.38-.26.38-2.31,1.21-2.93,1.32-6.58,1.18-9.6-5.39-10.98-11.11-2.08-8.64-3.48-17.89-4.84-26.83-.59-3.87-1.19-7.87-1.84-11.64l.17-3.44-.11-.1c-.15-.15-.34-.22-.57-.2-.25.02-1.01.09-3.12,3.05-4.42,6.19-4.12,8.32-3.1,15.38.54,3.77,1.36,7.69,2.15,11.48,1.08,5.21,2.2,10.6,2.65,15.71.15,1.75-.16,2.65-1.33,3.85-2.46,2.5-5.33,2.72-7.31,2.47-2.54-.33-5.15-1.65-7.16-3.62-.22-.21-.53-.63-.83-1.03-.44-.58-.85-1.13-1.16-1.35-.22-.15-.58-.4-1.03-.08l-.07.05-4.3,9.93-.03.12c-.03,1.1,1.7,2.68,3.96,4.63.74.63,1.37,1.18,1.7,1.55.19.76-1.79,3.84-2.93,5.24-5.59,6.92-16.72,13.68-26.31,13.23-2.8-.13-10.92-2.12-14.56-3.13-.35-.1-.58.36-.28.58,3.09,2.23,10.85,7.46,17.39,8.65,1.31.24,3.53.26,4.93.26l-.84.21-.69.17-.07.14c-.06.14-.07.29,0,.42.13.27.49.46,1.22.65.74.19,1.55.27,2.41.27,3.53,0,7.81-1.44,10.4-3.16,7.58-5.01,12.84-13.13,14.45-22.3,2.38-.09,6.55-.59,9.72-2.85,2.1-1.49,3.93-4.35,5.02-7.77l.98,3.14c2.23,5.21,6.28,8.5,10.83,8.8,4.27.28,8.26-2.2,10.97-6.78,3.65-6.18,4.95-12.61,3.98-19.64-.41-2.99-1.09-6.01-1.75-8.93-.65-2.88-1.32-5.84-1.73-8.78.4.16,1.08.36,1.63-.32Z"/>
<path class="cls-3" d="M72.15,88.67c1.62,0,3.24-.28,4.8-.83,3.76-1.34,6.73-4.15,8.38-7.93,4.34-9.96,2.27-14.86-4.37-22.74-.14-.17-.29-.4-.46-.65-.51-.78-1.14-1.75-2-1.61l-.11.04c-.51.27-2.95,5.9-2.97,5.96-.44,1.23-.88,2.66-.76,3.84.14,1.34,1.85,3.88,3.51,6.33.86,1.27,1.67,2.47,2.08,3.26l.1.18c.51.96.66,1.38.68,1.54-3.41,1.96-7.57,2.02-11.45.18-6.66-3.17-8.85-12.14-10.61-19.35-.39-1.61-.77-3.14-1.17-4.54.06-.25.62-.96.93-1.34.25-.31.46-.58.59-.78.65-1.05,1.34-2.33,1.68-3,.09-.17.19-.35.28-.53.49-.9,1.04-1.92,1.15-2.93l.1-.94-5.6,5.6-2.05-9.19-.11-.07c-.09-.06-.25-.13-.46-.09-.64.12-1.39,1.18-1.4,1.19-1.27,1.86-3.66,7.82-3.68,10.17,0,.46.09.91.19,1.36.07.35.15.68.17,1l-.41.15c-1.44.53-2.93,1.08-4.33,1.72l-.31.14c-5.44,2.47-12.21,5.54-15.63,10.65-1.37,2.04-3.89,7.89-4.26,10.23-.48,3.03.9,3.67,3.24,4.36,5.32,1.58,16.59.97,21.27-1.83,2.78-1.67,3.64-4.11,4.55-6.7.22-.62.44-1.26.7-1.89,1.09,5.27,3.89,13.81,10.81,17.37,2.21,1.14,4.57,1.71,6.92,1.71ZM33.63,68.58c.09-.06.17-.11.25-.17,2.4-1.68,5.2-3.12,7.92-4.51,2.74-1.4,5.57-2.86,8.04-4.57.31-.22.61-.5.91-.77.23-.22.45-.42.65-.56l2.11,8.77c-.48.47-3.29,1.78-4.2,2.11-4.94,1.79-10.34,1.96-16.51.51.09-.29.42-.52.85-.8Z"/>
<path class="cls-3" d="M106.79,87.85c3.39-.34,7.1-4.21,8.72-8.67,3.47,1.19,7.15,2.4,10.89,3.22l.33.07c2.85.63,5.89,1.23,6.85.8.48-.21.71-.84.9-1.35.04-.11.08-.21.12-.3.51-1.18,3.08-7.19,3.42-9.56.56-3.84-2.05-9.51-5.8-12.65-2.5-2.09-5.21-2.79-7.64-1.98-4.72,1.58-6.8,6.35-8.81,10.97-1.23,2.82-2.39,5.49-4.08,7.19-1.4,1.42-2.91,2.03-4.5,1.81-2.72-.37-6.25-2.79-7.12-5.45-3.05-9.37-4.66-28.07-7.84-47.56-.39-2.41-1.53-4.35-2.31-3.66-.89.79-5.65,6.21-6.03,7.42-.11.34-.27.57-.13.99,8.04,24.03,1.75,60.83,23.02,58.68ZM120.95,68.88c.71-1.13,1.64-1.57,2.85-1.36,2.43.43,5.04,3.37,5.7,4.44-1.33-.22-4.76-1.4-6.16-1.96l-.17-.07c-1.57-.61-2.06-.92-2.21-1.06Z"/>
<path class="cls-3" d="M96.14,94.72c-2.04-1.31-7.63-4.3-9.77-5.15-1.77-.7-1.99-.58-2.91.65l-.25.33c-.79,1.04-1.46,2.17-2.11,3.27-.43.72-.87,1.47-1.34,2.18-.47-.12-1.81-.82-3.02-1.44-2.21-1.14-4.97-2.57-6.47-2.78-.52-.07-1.04.17-1.33.63l-4.54,7.2c-.19.29-.24.65-.14.99.09.33.33.61.64.77l10.55,5.21c.18.09.37.13.57.13.44,0,.87-.22,1.11-.62l3.91-6.49c3.06.97,5.9,2.7,8.65,4.37l.09.05c.19.11.57.23.99-.14l5.67-7.49c.19-.26.27-.59.22-.9-.06-.32-.24-.6-.51-.77Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

1988
frontend/src/App.vue Normal file

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,451 @@
<template>
<div>
<section class="panel admin-head-panel">
<div class="admin-head">
<div>
<h2>{{ t('adminPanel') }}</h2>
<p class="muted">{{ t('adminPanelDesc') }}</p>
</div>
<div class="panel-actions">
<button class="btn btn-secondary" @click="$emit('refresh')">{{ t('actions.refresh') }}</button>
<button class="btn btn-danger" @click="$emit('logout')">{{ t('actions.logout') }}</button>
</div>
</div>
<div class="admin-settings-row">
<label class="switch-row">
<input type="checkbox" :checked="viewProofInView" @change="$emit('toggle-view-proof', $event.target.checked)" />
<span>{{ t('labels.viewProofInView') }}</span>
</label>
</div>
</section>
<nav class="tab-bar admin-tab-bar">
<button v-for="tab in adminTabs" :key="tab.id" class="tab-btn" :class="{ active: adminTab === tab.id }" @click="$emit('change-admin-tab', tab.id)">
{{ tab.label }}
</button>
</nav>
<PlayersManagementTab
v-show="adminTab === 'players'"
:t="t"
:group-setup-input="groupSetupInput"
:admin-group-cards="adminGroupCards"
:new-player="newPlayer"
:assignable-groups="assignableGroups"
:players-sorted="playersSorted"
:player-filter="playerFilter"
:player-sort="playerSort"
:player-image="playerImage"
:group-option-label="groupOptionLabel"
:normalized-group-code="normalizedGroupCode"
@update:group-setup-input="$emit('update:group-setup-input', $event)"
@save-group-setup="$emit('save-group-setup')"
@auto-group-even="$emit('auto-group-even')"
@update:new-player="$emit('update:new-player', $event)"
@create-player="$emit('create-player')"
@convert-new-name="$emit('convert-new-name', $event)"
@update:player-filter="$emit('update:player-filter', $event)"
@update:player-sort="$emit('update:player-sort', $event)"
@open-image-uploader="$emit('open-image-uploader', $event)"
@update-player-field="$emit('update-player-field', $event)"
@update-player-group="$emit('update-player-group', $event)"
@convert-row-name="$emit('convert-row-name', $event)"
@remove-player-image="$emit('remove-player-image', $event)"
@delete-player="$emit('delete-player', $event)"
/>
<section v-show="adminTab === 'liveMode'" class="panel">
<div class="panel-heading">
<h2>{{ t('liveMode') }}</h2>
<p>{{ t('labels.liveModeVisibility') }}</p>
</div>
<LiveSettingsCard
:t="t"
:live-settings="liveSettings"
:assignable-groups="assignableGroups"
:stage-control="stageControl"
:stage-options="stageOptions"
:current-stage="currentStage"
:last-stage="lastStage"
@update-live-setting="$emit('update-live-setting', $event)"
@update-stage-control="$emit('update-stage-control', $event)"
@start-stage="$emit('start-stage')"
@end-stage="$emit('end-stage')"
@navigate-current-stage="$emit('navigate-current-stage')"
/>
</section>
<section v-show="adminTab === 'preliminary'" class="panel">
<div class="panel-heading">
<h2>{{ t('sections.preliminaryAdminTitle') }}</h2>
<p>{{ t('sections.preliminaryAdminSubtitle') }}</p>
</div>
<div class="panel-actions">
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'preliminary')">{{ t('actions.resetScores') }}</button>
</div>
<div class="stage-filter-bar">
<input
class="name-input"
:value="scoreFilters.preliminary"
:placeholder="t('actions.searchPlayer')"
@input="$emit('update-score-filter', { stage: 'preliminary', value: $event.target.value })"
/>
</div>
<PositionBoardEditor
:t="t"
board="preliminary"
:rows="preliminaryScoreRows"
:slots="positionBoards.preliminary"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:available-groups="assignableGroups"
@save="$emit('save-position-board', $event)"
/>
<MultiRoundScoreEditor
:t="t"
:rows="preliminaryScoreRows"
:round-stages="[
{ stage: 'prelim_r1', label: t('table.round1') },
{ stage: 'prelim_r2', label: t('table.round2') },
{ stage: 'prelim_r3', label: t('table.round3') },
]"
:total-score="preliminaryTotalScore"
:show-filter="false"
:filter-text="scoreFilters.preliminary"
:section-by-group="true"
:overflow-limit="6"
:highlight-stage="activeScoringStage"
:highlight-group="activeScoringGroup"
:show-group="true"
:show-rank="false"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
:on-score-focus="onScoreFocus"
:on-score-input="onScoreInput"
:on-score-commit="onScoreCommit"
:has-score-proof="hasScoreProof"
:score-proof-for="scoreProofFor"
:open-score-proof-uploader="openScoreProofUploader"
:open-proof-preview="openProofPreview"
:show-proof-controls="false"
/>
</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>
<PositionBoardEditor
:t="t"
board="prelim_tiebreak"
:rows="prelimTieScoreRows"
:slots="positionBoards.prelim_tiebreak"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:numeric-groups="true"
@save="$emit('save-position-board', $event)"
/>
<ScoreStageEditor
:t="t"
stage="prelim_tiebreak"
color-mode="score"
:rows="prelimTieScoreRows"
:filter-text="scoreFilters.prelim_tiebreak"
:section-by-group="true"
:tie-break-section-mode="true"
:show-score-before-input="true"
:input-label="t('table.tieScore')"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
: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"
:highlight-stage="activeScoringStage"
:highlight-group="activeScoringGroup"
:show-proof-controls="false"
@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>
<button class="btn btn-outline" @click="$emit('reset-final-group-by-seed')">{{ t('actions.resetFinalGroupBySeed') }}</button>
</div>
<div class="stage-filter-bar">
<input
class="name-input"
:value="scoreFilters.final_common"
:placeholder="t('actions.searchPlayer')"
@input="$emit('update-score-filter', { stage: 'final_common', value: $event.target.value })"
/>
</div>
<PositionBoardEditor
:t="t"
board="final"
:rows="finalScoreRows"
:slots="positionBoards.final"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:numeric-groups="true"
@save="$emit('save-position-board', $event)"
/>
<MultiRoundScoreEditor
:t="t"
:rows="finalScoreRows"
:round-stages="[
{ stage: 'final_r1', label: t('table.round1') },
{ stage: 'final_r2', label: t('table.round2') },
]"
:total-score="finalTotalScore"
:show-filter="false"
:filter-text="scoreFilters.final_common"
:section-by-group="true"
:overflow-limit="6"
:highlight-stage="activeScoringStage"
:highlight-group="activeScoringGroup"
:show-group="true"
:show-seed="true"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
:on-score-focus="onScoreFocus"
:on-score-input="onScoreInput"
:on-score-commit="onScoreCommit"
:has-score-proof="hasScoreProof"
:score-proof-for="scoreProofFor"
:open-score-proof-uploader="openScoreProofUploader"
:open-proof-preview="openProofPreview"
:show-proof-controls="false"
/>
</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>
<div class="panel-actions">
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final_tiebreak')">{{ t('actions.resetScores') }}</button>
</div>
<template v-if="finalTie.required">
<PositionBoardEditor
:t="t"
board="final_tiebreak"
:rows="finalTieScoreRows"
:slots="positionBoards.final_tiebreak"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:numeric-groups="true"
@save="$emit('save-position-board', $event)"
/>
<ScoreStageEditor
:t="t"
stage="final_tiebreak"
color-mode="score"
:rows="finalTieScoreRows"
:filter-text="scoreFilters.final_tiebreak"
:section-by-group="true"
:tie-break-section-mode="true"
:show-score-before-input="true"
:input-label="t('table.tieScore')"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
: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"
:highlight-stage="activeScoringStage"
:highlight-group="activeScoringGroup"
:show-proof-controls="false"
@update:filter="$emit('update-score-filter', { stage: 'final_tiebreak', value: $event })"
/>
</template>
<h3 class="sub-heading mt-32">{{ t('sections.finalRanking') }}</h3>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.rank') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.score') }}</th>
<th>{{ t('table.tieScore') }}</th>
<th>{{ t('table.medal') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in finalRows" :key="'a-fr-' + row.playerId" :class="{ 'podium-row': row.rank <= 3 }">
<td class="mono rank">{{ row.rank }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td class="mono strong">{{ row.score }}</td>
<td class="mono">{{ row.tieBreak }}</td>
<td>
<span v-if="row.rank === 1">🥇</span>
<span v-else-if="row.rank === 2">🥈</span>
<span v-else-if="row.rank === 3">🥉</span>
<span v-else class="muted"></span>
</td>
</tr>
<tr v-if="finalRows.length === 0"><td colspan="5" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
<script setup>
import PlayersManagementTab from './admin/PlayersManagementTab.vue'
import ScoreStageEditor from './admin/ScoreStageEditor.vue'
import LiveSettingsCard from './admin/LiveSettingsCard.vue'
import MultiRoundScoreEditor from './admin/MultiRoundScoreEditor.vue'
import PositionBoardEditor from './admin/PositionBoardEditor.vue'
defineProps({
t: { type: Function, required: true },
adminTabs: { type: Array, required: true },
adminTab: { type: String, required: true },
viewProofInView: { type: Boolean, default: false },
liveSettings: { type: Object, required: true },
stageControl: { type: Object, required: true },
stageOptions: { type: Object, required: true },
currentStage: { type: Object, default: null },
lastStage: { type: Object, default: null },
activeScoringStage: { type: String, default: '' },
activeScoringGroup: { type: String, default: '' },
groupSetupInput: { type: String, default: '' },
adminGroupCards: { type: Array, required: true },
newPlayer: { type: Object, required: true },
assignableGroups: { type: Array, required: true },
playersSorted: { type: Array, required: true },
playerFilter: { type: String, default: '' },
playerSort: { type: String, default: 'id' },
playerImage: { type: Function, required: true },
groupOptionLabel: { type: Function, required: true },
normalizedGroupCode: { type: Function, required: true },
preliminaryScoreRows: { type: Array, required: true },
prelimTieScoreRows: { type: Array, required: true },
finalScoreRows: { type: Array, required: true },
finalTieScoreRows: { type: Array, required: true },
finalRows: { type: Array, required: true },
preliminaryTotalScore: { type: Function, required: true },
finalTotalScore: { type: Function, required: true },
prelimTie: { type: Object, required: true },
finalTie: { type: Object, required: true },
positionBoards: { type: Object, required: true },
scoreFilters: { type: Object, required: true },
displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true },
scoreInputValue: { type: Function, required: true },
scoreInputInvalid: { type: Function, required: true },
onScoreFocus: { type: Function, required: true },
onScoreInput: { type: Function, required: true },
onScoreCommit: { type: Function, required: true },
hasScoreProof: { type: Function, required: true },
scoreProofFor: { type: Function, required: true },
openScoreProofUploader: { type: Function, required: true },
removeScoreProof: { type: Function, required: true },
openProofPreview: { type: Function, required: true },
requestScoreAdvice: { type: Function, required: true },
})
defineEmits([
'refresh',
'logout',
'toggle-view-proof',
'update-live-setting',
'update-stage-control',
'start-stage',
'end-stage',
'navigate-current-stage',
'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',
'reset-final-group-by-seed',
'save-position-board',
'update-score-filter',
])
</script>

View File

@@ -0,0 +1,148 @@
<template>
<header class="masthead">
<div class="masthead-main">
<div class="masthead-brand">
<picture>
<source v-if="mode !== 'live'" srcset="/logo.svg" type="image/svg+xml" />
<img class="masthead-logo" :src="mode === 'live' ? '/logo_white.png' : '/logo.png'" :alt="competitionTitle" />
</picture>
<p class="masthead-subtitle">{{ t('subtitle') }}</p>
</div>
<div class="masthead-controls">
<div ref="controlsRoot" class="controls-popup-anchor">
<div class="control-toggle-row">
<button class="control-toggle-btn" type="button" :aria-expanded="controlsOpen ? 'true' : 'false'" @click="toggleControls">
<span>{{ optionsLabel }}</span>
<span class="control-toggle-meta">{{ modeShort }} · {{ languageShort }}</span>
</button>
<button
class="control-icon-btn"
type="button"
:class="{ active: isFullscreen }"
:aria-label="fullscreenActionLabel"
:title="fullscreenActionLabel"
@click="toggleFullscreen"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
v-if="!isFullscreen"
d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
v-else
d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<Transition name="controls-reveal">
<div v-if="controlsOpen" class="controls-grid controls-popover">
<div class="control-box">
<p class="control-label">{{ t('labels.mode') }}</p>
<div class="language-switcher mode-switcher">
<!-- <button class="lang-btn" :class="{ active: mode === 'view' }" @click="changeMode('view')">{{ t('viewMode') }}</button> -->
<button class="lang-btn" :class="{ active: mode === 'live' }" @click="changeMode('live')">{{ t('liveMode') }}</button>
<button class="lang-btn" :class="{ active: mode === 'admin' }" @click="changeMode('admin')">{{ t('adminMode') }}</button>
</div>
</div>
<div class="control-box">
<p class="control-label">{{ t('labels.language') }}</p>
<div class="language-switcher">
<button class="lang-btn" :class="{ active: language === 'ar' }" @click="changeLanguage('ar')">العربية</button>
<button class="lang-btn" :class="{ active: language === 'en' }" @click="changeLanguage('en')">English</button>
</div>
</div>
</div>
</Transition>
</div>
<div class="status-row">
<div class="live-badge">{{ modeStatusLabel }}</div>
<div class="server-time" v-if="serverTime">{{ t('labels.lastSync') }}: {{ serverTime }}</div>
</div>
</div>
</div>
</header>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
const props = defineProps({
t: { type: Function, required: true },
competitionTitle: { type: String, required: true },
mode: { type: String, required: true },
language: { type: String, required: true },
serverTime: { type: String, default: '' },
isFullscreen: { type: Boolean, default: false },
})
const emit = defineEmits(['change-mode', 'change-language', 'toggle-fullscreen'])
const controlsOpen = ref(false)
const controlsRoot = ref(null)
const optionsLabel = computed(() => (props.language === 'ar' ? 'خيارات' : 'Options'))
const modeShort = computed(() => {
if (props.mode === 'view') return props.language === 'ar' ? 'عرض' : 'View'
if (props.mode === 'live') return props.language === 'ar' ? 'حي' : 'Live'
return props.language === 'ar' ? 'إدارة' : 'Admin'
})
const languageShort = computed(() => (props.language === 'ar' ? 'AR' : 'EN'))
const fullscreenActionLabel = computed(() => (props.isFullscreen ? props.t('actions.exitFullscreen') : props.t('actions.enterFullscreen')))
const modeStatusLabel = computed(() => {
if (props.mode === 'view') return '● ' + props.t('labels.liveTracker')
if (props.mode === 'live') return '● ' + props.t('liveMode')
return props.t('adminPanel')
})
function toggleControls() {
controlsOpen.value = !controlsOpen.value
}
function changeMode(nextMode) {
emit('change-mode', nextMode)
controlsOpen.value = false
}
function changeLanguage(nextLanguage) {
emit('change-language', nextLanguage)
controlsOpen.value = false
}
function toggleFullscreen() {
emit('toggle-fullscreen')
}
function onGlobalPointerDown(event) {
if (!controlsOpen.value) return
const root = controlsRoot.value
if (root && !root.contains(event.target)) controlsOpen.value = false
}
function onGlobalKeyDown(event) {
if (event.key === 'Escape') controlsOpen.value = false
}
onMounted(() => {
window.addEventListener('pointerdown', onGlobalPointerDown)
window.addEventListener('keydown', onGlobalKeyDown)
})
onBeforeUnmount(() => {
window.removeEventListener('pointerdown', onGlobalPointerDown)
window.removeEventListener('keydown', onGlobalKeyDown)
})
</script>

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

View File

@@ -0,0 +1,639 @@
<template>
<div class="live-mode-root">
<section class="live-mode-shell">
<div class="live-mode-overlay">
<!-- <div class="live-screen-head live-head-selection">
<span class="live-chip strong">{{ activeViewLabel }}</span>
</div> -->
<div class="live-mode-grid single">
<article v-if="activeView === 'group_live'" class="live-screen-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.liveTitle') }}</h3>
<div class="live-chip-row">
<span class="live-chip">{{ liveSettings.groupDisplayMode === 'fixed' ? t('actions.liveModeFixed') : t('actions.liveModeRotate') }}</span>
</div>
</div>
<Transition name="live-swap" mode="out-in">
<div :key="'live-group-swap-' + (liveGroupCode || 'u')" class="live-swap-block">
<div class="live-focus-banner" :class="'focus-' + groupClassKey">
<span class="live-focus-label">{{ t('labels.group') }}</span>
<strong class="live-focus-title">{{ liveGroupCode || t('labels.unassigned') }}</strong>
</div>
<div class="table-wrap">
<table class="score-table live-score-table">
<thead>
<tr>
<th>{{ t('table.position') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.round1') }}</th>
<th>{{ t('table.round2') }}</th>
<th>{{ t('table.round3') }}</th>
<th>{{ t('table.total') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id">
<td class="mono rank">{{ index + 1 }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(entry.player)" :alt="entry.player.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(entry.player) }}</p>
</div>
</div>
</td>
<td class="mono">{{ scoreFor('prelim_r1', entry.player.id) }}</td>
<td class="mono">{{ scoreFor('prelim_r2', entry.player.id) }}</td>
<td class="mono">{{ scoreFor('prelim_r3', entry.player.id) }}</td>
<td class="mono strong">{{ entry.total }}</td>
</tr>
<tr v-if="liveGroupMembers.length === 0"><td colspan="6" class="muted center">{{ t('labels.emptyLive') }}</td></tr>
</tbody>
</table>
</div>
</div>
</Transition>
</article>
<article v-else-if="activeView === 'prelim_tie'" class="live-screen-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.prelimTieTitle') }}</h3>
<div class="live-chip-row">
<span class="live-chip">{{ t('table.tieScore') }}</span>
<span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span>
</div>
</div>
<Transition name="live-swap" mode="out-in">
<article v-if="activePrelimTieGroup" :key="'live-pre-tie-group-' + activePrelimTieGroup.groupKey" class="live-tie-group-card">
<h4 class="live-tie-group-title">{{ t('labels.group') }} {{ activePrelimTieGroup.groupKey }}</h4>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.position') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.tieScore') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in activePrelimTieGroup.rows" :key="'live-pre-tie-' + activePrelimTieGroup.groupKey + '-' + entry.row.playerId">
<td class="mono">{{ entry.position }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(entry.row) }}</p>
</div>
</div>
</td>
<td class="mono strong">{{ entry.row.tieBreak }}</td>
</tr>
</tbody>
</table>
</div>
</article>
</Transition>
<div v-if="groupedPrelimTieRows.length === 0" class="muted center">{{ t('messages.noPrelimTie') }}</div>
</article>
<article v-else-if="activeView === 'prelim_overall'" class="live-screen-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.overallTitle') }}</h3>
<div class="live-chip-row">
<span class="live-chip strong">{{ t('labels.top12') }}: {{ finalists.length }}</span>
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
</div>
</div>
<Transition name="live-swap" mode="out-in">
<div :key="'prelim-overall-page-' + listPageIndex" class="live-swap-block">
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.rank') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.total') }}</th>
<th>{{ t('table.tieScore') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in pagedPreliminaryRows"
:key="'live-pre-overall-' + row.playerId"
>
<td class="mono rank">
<div class="live-rank-cell">
<span>{{ row.rank }}</span>
<span v-if="prelimOverallHintLabel(row)" class="live-rank-hint">{{ prelimOverallHintLabel(row) }}</span>
</div>
</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>
</div>
</div>
</td>
<td class="mono strong">{{ row.score }}</td>
<td class="mono">{{ row.tieBreak }}</td>
</tr>
<tr v-if="preliminaryRowsForLive.length === 0"><td colspan="4" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
</tbody>
</table>
</div>
</div>
</Transition>
</article>
<article v-else-if="activeView === 'final_groups'" class="live-screen-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.finalTitle') }}</h3>
<div class="live-chip-row">
<span class="live-chip">{{ liveSettings.finalDisplayMode === 'fixed' ? t('actions.liveModeFixed') : t('actions.liveModeRotate') }}</span>
</div>
</div>
<Transition name="live-swap" mode="out-in">
<div :key="'live-final-swap-' + liveFinalGroup" class="live-swap-block">
<div class="live-focus-banner focus-final">
<span class="live-focus-label">{{ currentFinalSeedLabel }}</span>
<strong class="live-focus-title">{{ currentFinalGroupNumber }}</strong>
</div>
<div class="table-wrap">
<table class="score-table live-score-table">
<thead>
<tr>
<th>{{ t('table.position') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.round1') }}</th>
<th>{{ t('table.round2') }}</th>
<th>{{ t('table.total') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, index) in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId">
<td class="mono rank">{{ index + 1 }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(entry.row) }}</p>
</div>
</div>
</td>
<td class="mono">{{ scoreFor('final_r1', entry.row.playerId) }}</td>
<td class="mono">{{ scoreFor('final_r2', entry.row.playerId) }}</td>
<td class="mono strong">{{ entry.total }}</td>
</tr>
<tr v-if="liveFinalRows.length === 0"><td colspan="5" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody>
</table>
</div>
</div>
</Transition>
</article>
<article v-else-if="activeView === 'final_tie'" class="live-screen-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.finalTieTitle') }}</h3>
<div class="live-chip-row">
<span class="live-chip">{{ t('table.tieScore') }}</span>
<span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span>
</div>
</div>
<Transition name="live-swap" mode="out-in">
<article v-if="activeFinalTieGroup" :key="'live-final-tie-group-' + activeFinalTieGroup.groupKey" class="live-tie-group-card">
<h4 class="live-tie-group-title">{{ t('labels.group') }} {{ activeFinalTieGroup.groupKey }}</h4>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.position') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.tieScore') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in activeFinalTieGroup.rows" :key="'live-final-tie-' + activeFinalTieGroup.groupKey + '-' + entry.row.playerId">
<td class="mono">{{ entry.position }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(entry.row) }}</p>
</div>
</div>
</td>
<td class="mono strong">{{ entry.row.tieBreak }}</td>
</tr>
</tbody>
</table>
</div>
</article>
</Transition>
<div v-if="groupedFinalTieRows.length === 0" class="muted center">{{ t('messages.noFinalTie') }}</div>
</article>
<article v-else-if="activeView === 'final_overall'" class="live-screen-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.finalRanking') }}</h3>
<div class="live-chip-row">
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
</div>
</div>
<Transition name="live-swap" mode="out-in">
<div :key="'final-overall-page-' + listPageIndex" class="live-swap-block">
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.rank') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.score') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in pagedFinalOverallRows" :key="'live-final-overall-' + row.playerId">
<td class="mono rank">
<div class="live-rank-cell">
<span>{{ row.rank }}</span>
<span v-if="finalOverallHintLabel(row)" class="live-rank-hint">{{ finalOverallHintLabel(row) }}</span>
</div>
</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>
</div>
</div>
</td>
<td class="mono strong">{{ row.score }}</td>
</tr>
<tr v-if="finalOverallRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody>
</table>
</div>
</div>
</Transition>
</article>
<article v-else class="live-screen-card podium-live-card wide">
<div class="live-screen-head">
<h3>{{ t('sections.podiumTitle') }}</h3>
</div>
<div class="podium-wrapper live-podium">
<article class="podium-col pos-2">
<div class="podium-avatar-wrap">
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
</div>
<div class="podium-medal-icon">🥈</div>
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[0])" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
<div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
</div>
</article>
<article class="podium-col pos-1">
<div class="podium-avatar-wrap">
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
</div>
<div class="podium-medal-icon">🥇</div>
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[1])" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
<div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
</div>
</article>
<article class="podium-col pos-3">
<div class="podium-avatar-wrap">
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
</div>
<div class="podium-medal-icon">🥉</div>
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[2])" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
<div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
</div>
</article>
</div>
</article>
</div>
</div>
<footer class="live-mode-footer">
<div class="live-footer-slot left ">
<img src="/datwyler_logo.png" alt="Datwyler logo" class=" live-footer-logo live-footer-logo-datwyler " />
</div>
<div class="live-footer-slot center">
<img src="/arture.png" alt="Arture logo" class="live-footer-logo" />
</div>
<div class="live-footer-slot right">
<img src="/simon_logo.png" alt="Simon logo" class="live-footer-logo" />
</div>
</footer>
</section>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
const props = defineProps({
t: { type: Function, required: true },
liveSettings: { type: Object, required: true },
positionBoards: { type: Object, default: () => ({}) },
liveGroupCode: { type: String, default: '' },
liveGroupMembers: { type: Array, required: true },
prelimTie: { type: Object, default: () => ({ required: false, resolved: true, slots: 0, playerIds: [] }) },
prelimTieRows: { type: Array, required: true },
preliminaryRows: { type: Array, required: true },
finalists: { type: Array, required: true },
liveFinalGroup: { type: Number, required: true },
liveFinalRows: { type: Array, required: true },
finalOverallRows: { type: Array, required: true },
finalTieRows: { type: Array, required: true },
podiumOrdered: { type: Array, required: true },
playerImage: { type: Function, required: true },
displayName: { type: Function, required: true },
scoreFor: { type: Function, required: true },
prelimTotal: { type: Function, required: true },
finalTotal: { type: Function, required: true },
podiumImage: { type: Function, required: true },
podiumName: { type: Function, required: true },
podiumHasResult: { type: Function, required: true },
podiumScoreMain: { type: Function, required: true },
podiumTieSubtitle: { type: Function, required: true },
})
const finalistIds = computed(() => new Set((props.finalists || []).map((row) => row.playerId)))
const prelimTieIds = computed(() => new Set((props.prelimTieRows || []).map((row) => row.playerId)))
const finalTieIds = computed(() => new Set((props.finalTieRows || []).map((row) => row.playerId)))
const prelimTieResolved = computed(() => Boolean(props.prelimTie?.resolved))
const activeView = computed(() => props.liveSettings.activeView || 'group_live')
const listPageIndex = ref(0)
const tieGroupIndex = ref(0)
let listRotationTimer = null
let tieRotationTimer = null
const rotationIntervalSec = computed(() => {
const raw = Number(props.liveSettings.rotationIntervalSec || 5)
if (!Number.isFinite(raw)) return 5
return Math.max(3, raw)
})
const rotationPlayerCount = computed(() => {
const raw = Number(props.liveSettings.rotationPlayerCount || 12)
if (!Number.isFinite(raw)) return 12
if (raw < 3) return 3
if (raw > 40) return 40
return Math.floor(raw)
})
const preliminaryRowsForLive = computed(() => rankPreliminaryRowsWithTieBreak(props.preliminaryRows || []))
const listRowsForActiveView = computed(() => {
if (activeView.value === 'prelim_overall') return preliminaryRowsForLive.value
if (activeView.value === 'final_overall') return props.finalOverallRows || []
return []
})
const listPageCount = computed(() => {
const total = listRowsForActiveView.value.length
if (total <= 0) return 1
return Math.max(1, Math.ceil(total / rotationPlayerCount.value))
})
const currentListPageDisplay = computed(() => `${listPageIndex.value + 1} / ${listPageCount.value}`)
const pagedPreliminaryRows = computed(() => paginateRows(preliminaryRowsForLive.value))
const pagedFinalOverallRows = computed(() => paginateRows(props.finalOverallRows || []))
const groupedPrelimTieRows = computed(() => groupTieRowsByPositionBoard('prelim_tiebreak', props.prelimTieRows || []))
const groupedFinalTieRows = computed(() => groupTieRowsByPositionBoard('final_tiebreak', props.finalTieRows || []))
const tieGroupCountForActiveView = computed(() => {
if (activeView.value === 'prelim_tie') return groupedPrelimTieRows.value.length
if (activeView.value === 'final_tie') return groupedFinalTieRows.value.length
return 0
})
const currentTieGroupPageDisplay = computed(() => {
const total = tieGroupCountForActiveView.value
if (total <= 0) return '0 / 0'
return `${(tieGroupIndex.value % total) + 1} / ${total}`
})
const activePrelimTieGroup = computed(() => {
const groups = groupedPrelimTieRows.value
if (groups.length === 0) return null
return groups[tieGroupIndex.value % groups.length]
})
const activeFinalTieGroup = computed(() => {
const groups = groupedFinalTieRows.value
if (groups.length === 0) return null
return groups[tieGroupIndex.value % groups.length]
})
const rankedLiveGroupMembers = computed(() => {
return (props.liveGroupMembers || []).map((player) => ({
player,
total: Number(props.prelimTotal(player.id) || 0),
}))
})
const activeViewLabel = computed(() => {
const map = {
group_live: props.t('labels.liveViewGroup'),
prelim_tie: props.t('labels.liveViewPrelimTie'),
prelim_overall: props.t('labels.liveViewPrelimOverall'),
final_groups: props.t('labels.liveViewFinalGroups'),
final_tie: props.t('labels.liveViewFinalTie'),
final_overall: props.t('labels.liveViewFinalOverall'),
podium: props.t('labels.liveViewPodium'),
}
return map[activeView.value] || map.group_live
})
const currentFinalSeedLabel = computed(() => {
if (props.liveFinalGroup === 2) return props.t('labels.finalGroupSeeds2')
return props.t('labels.finalGroupSeeds1')
})
const currentFinalGroupNumber = computed(() => (props.liveFinalGroup === 2 ? '2' : '1'))
const groupClassKey = computed(() => {
const code = String(props.liveGroupCode || '').trim().toUpperCase()
if (code.startsWith('A')) return 'a'
if (code.startsWith('B')) return 'b'
if (code.startsWith('C')) return 'c'
if (code.startsWith('D')) return 'd'
return 'u'
})
const rankedLiveFinalRows = computed(() => {
return (props.liveFinalRows || []).map((row) => ({
row,
total: Number(props.finalTotal(row.playerId) || 0),
}))
})
function isFinalist(playerId) {
return finalistIds.value.has(playerId)
}
function isPrelimTiePlayer(playerId) {
return prelimTieIds.value.has(playerId)
}
function isFinalTiePlayer(playerId) {
return finalTieIds.value.has(playerId)
}
function prelimOverallHintLabel(row) {
if (!prelimTieResolved.value && isPrelimTiePlayer(row.playerId)) return props.t('labels.tieBreak')
if (isFinalist(row.playerId)) return props.t('labels.finalist')
return ''
}
function finalOverallHintLabel(row) {
if (isFinalTiePlayer(row.playerId)) return props.t('labels.tieBreak')
return ''
}
watch(
() =>
`${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalOverallRows.length}|${props.finalTieRows.length}`,
() => {
restartListRotation()
restartTieRotation()
},
{ immediate: true },
)
onBeforeUnmount(() => {
stopListRotation()
stopTieRotation()
})
function paginateRows(rows) {
const start = listPageIndex.value * rotationPlayerCount.value
return rows.slice(start, start + rotationPlayerCount.value)
}
function rankPreliminaryRowsWithTieBreak(rows) {
const sorted = [...(rows || [])].sort((a, b) => {
const scoreA = Number(a.score || 0)
const scoreB = Number(b.score || 0)
if (scoreA !== scoreB) return scoreB - scoreA
const tieA = Number(a.tieBreak || 0)
const tieB = Number(b.tieBreak || 0)
if (tieA !== tieB) return tieB - tieA
return Number(a.playerId || 0) - Number(b.playerId || 0)
})
let currentRank = 1
return sorted.map((row, index) => {
if (index > 0) {
const prev = sorted[index - 1]
const isSameScore = Number(prev.score || 0) === Number(row.score || 0)
const isSameTieBreak = Number(prev.tieBreak || 0) === Number(row.tieBreak || 0)
if (!(isSameScore && isSameTieBreak)) {
currentRank = index + 1
}
}
return { ...row, rank: currentRank }
})
}
function restartListRotation() {
stopListRotation()
listPageIndex.value = 0
const autoRotateView = activeView.value === 'prelim_overall' || activeView.value === 'final_overall'
if (!autoRotateView) return
if (listPageCount.value <= 1) return
listRotationTimer = setInterval(() => {
if (listPageCount.value <= 1) {
listPageIndex.value = 0
return
}
listPageIndex.value = (listPageIndex.value + 1) % listPageCount.value
}, rotationIntervalSec.value * 1000)
}
function stopListRotation() {
if (listRotationTimer) {
clearInterval(listRotationTimer)
listRotationTimer = null
}
}
function restartTieRotation() {
stopTieRotation()
tieGroupIndex.value = 0
const total = tieGroupCountForActiveView.value
if (total <= 1) return
if (activeView.value !== 'prelim_tie' && activeView.value !== 'final_tie') return
tieRotationTimer = setInterval(() => {
if (tieGroupCountForActiveView.value <= 1) {
tieGroupIndex.value = 0
return
}
tieGroupIndex.value = (tieGroupIndex.value + 1) % tieGroupCountForActiveView.value
}, rotationIntervalSec.value * 1000)
}
function stopTieRotation() {
if (tieRotationTimer) {
clearInterval(tieRotationTimer)
tieRotationTimer = null
}
}
function boardSlot(board, playerId) {
const boardMap = props.positionBoards?.[board] || {}
return boardMap[String(playerId)] || boardMap[playerId] || null
}
function groupTieRowsByPositionBoard(board, rows) {
const mapped = (rows || []).map((row, index) => {
const slot = boardSlot(board, row.playerId)
const fallbackGroup = String(Math.floor(index / 6) + 1)
const fallbackPos = (index % 6) + 1
const groupKeyRaw = String(slot?.groupKey || fallbackGroup).trim()
const parsedGroup = Number.parseInt(groupKeyRaw, 10)
const groupKey = Number.isFinite(parsedGroup) && parsedGroup > 0 ? String(parsedGroup) : fallbackGroup
const position = Number(slot?.position) > 0 ? Math.floor(Number(slot.position)) : fallbackPos
return { row, groupKey, position }
})
const groups = new Map()
for (const entry of mapped) {
if (!groups.has(entry.groupKey)) groups.set(entry.groupKey, [])
groups.get(entry.groupKey).push(entry)
}
return [...groups.entries()]
.sort((a, b) => Number(a[0]) - Number(b[0]))
.map(([groupKey, items]) => ({
groupKey,
rows: items.sort((a, b) => {
if (a.position !== b.position) return a.position - b.position
return a.row.playerId - b.row.playerId
}),
}))
}
</script>

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

View File

@@ -0,0 +1,23 @@
<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-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>

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

View File

@@ -0,0 +1,477 @@
<template>
<div>
<nav class="tab-bar">
<button v-for="tab in viewTabs" :key="tab.id" class="tab-btn" :class="{ active: viewTab === tab.id }" @click="$emit('change-tab', tab.id)">
{{ tab.label }}
</button>
</nav>
<section v-show="viewTab === 'groups'" class="panel">
<div class="panel-heading">
<h2>{{ t('sections.groupsTitle') }}</h2>
<p>{{ t('sections.groupsSubtitle') }}</p>
</div>
<div class="summary-grid">
<article v-for="group in groupSummaries" :key="group.code" class="summary-card" :class="'group-' + group.key">
<h3>{{ t('labels.group') }} {{ group.code }}</h3>
<p class="summary-value">{{ group.count }}</p>
<p class="summary-status ok">{{ t('labels.players') }}</p>
</article>
</div>
<div v-if="groupedPlayers.length > 0" class="view-group-grid">
<article v-for="group in groupedPlayers" :key="'view-group-' + group.code" class="view-group-card" :class="'group-' + group.key">
<div class="view-group-card-head">
<h3>{{ groupLabel(group.code) }}</h3>
<span class="pm-count-badge">{{ group.players.length }}</span>
</div>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>#</th>
<th>{{ t('table.competitor') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="player in group.players" :key="'v-g-' + group.code + '-' + player.id" :class="rowClass(player.groupCode)">
<td class="mono">{{ player.id }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(player)" :alt="player.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(player) }}</p>
<p class="name-sub">{{ secondaryName(player) }}</p>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</article>
</div>
<div v-else class="table-wrap">
<table class="score-table">
<tbody>
<tr><td class="muted center">{{ t('labels.noPlayers') }}</td></tr>
</tbody>
</table>
</div>
</section>
<section v-show="viewTab === 'live'" class="panel live-panel">
<div class="live-corner">
<div class="live-top-controls">
<button class="btn btn-outline light" :class="{ active: liveMode === 'rotate' }" @click="$emit('change-live-mode', 'rotate')">
{{ t('actions.liveRotate') }}
</button>
<button class="btn btn-outline light" :class="{ active: liveMode === 'fixed' }" @click="$emit('change-live-mode', 'fixed')">
{{ t('actions.liveFixed') }}
</button>
<select
v-if="liveMode === 'fixed'"
class="group-select"
:value="selectedLiveGroup"
@change="$emit('change-live-group', $event.target.value)"
>
<option v-for="group in liveSelectableGroups" :key="'live-group-' + group" :value="group">{{ group }}</option>
</select>
</div>
</div>
<div class="live-title">{{ t('sections.liveTitle') }} · {{ liveGroupCode || t('labels.unassigned') }}</div>
<p class="live-subtitle">{{ t('sections.liveSubtitle') }}</p>
<div class="live-grid" v-if="liveMembers.length > 0">
<article class="live-card" v-for="member in liveMembers" :key="'live-' + member.id">
<img class="live-image" :src="playerImage(member)" :alt="member.nameAr" />
<div>
<p class="live-number">#{{ member.id }}</p>
<p class="live-name-primary">{{ displayName(member) }}</p>
<p class="live-name-secondary">{{ secondaryName(member) }}</p>
</div>
</article>
</div>
<div class="empty-state" v-else>{{ t('labels.emptyLive') }}</div>
<div class="live-progress" :key="'tick-' + liveTick" />
</section>
<section v-show="viewTab === 'overall'" class="panel">
<div class="panel-heading">
<h2>{{ t('sections.overallTitle') }}</h2>
<p>{{ t('sections.overallSubtitle') }}</p>
</div>
<div class="two-column">
<div>
<h3 class="sub-heading">{{ t('labels.allPlayers') }}</h3>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.rank') }}</th>
<th>#</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.group') }}</th>
<th>{{ t('table.score') }}</th>
<th>{{ t('table.tieScore') }}</th>
<th>{{ t('table.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in preliminaryRows" :key="'v-pr-' + row.playerId" :class="{ 'qualified-row': isFinalist(row.playerId) }">
<td class="mono rank">{{ row.rank }}</td>
<td class="mono">{{ row.playerId }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
<td class="mono strong">
<span>{{ row.score }}</span>
<button
v-if="canViewProofs && preliminaryProofStage(row.playerId)"
class="proof-mini"
@click="$emit('open-proof', { stage: preliminaryProofStage(row.playerId), playerId: row.playerId })"
>
<img :src="scoreProofFor(preliminaryProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
</button>
</td>
<td class="mono">
<span>{{ row.tieBreak }}</span>
<button
v-if="canViewProofs && hasScoreProof('prelim_tiebreak', row.playerId)"
class="proof-mini"
@click="$emit('open-proof', { stage: 'prelim_tiebreak', playerId: row.playerId })"
>
<img :src="scoreProofFor('prelim_tiebreak', row.playerId)" :alt="t('table.verification')" />
</button>
</td>
<td>
<span v-if="isFinalist(row.playerId)" class="badge success">{{ t('labels.finalist') }}</span>
<span v-else class="muted">{{ t('labels.notFinalist') }}</span>
</td>
</tr>
<tr v-if="preliminaryRows.length === 0"><td colspan="7" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
</tbody>
</table>
</div>
</div>
<div>
<h3 class="sub-heading">{{ t('labels.top12') }}</h3>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.seed') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.group') }}</th>
<th>{{ t('table.score') }}</th>
<th>{{ t('table.tieScore') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in finalists" :key="'v-top-' + row.playerId" class="qualified-row">
<td class="mono rank">{{ row.seed }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
<td class="mono strong">{{ row.score }}</td>
<td class="mono">{{ row.tieBreak }}</td>
</tr>
<tr v-if="finalists.length === 0"><td colspan="5" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="hint-box danger" v-if="prelimTie.required && !prelimTie.resolved">
{{ t('messages.prelimTieUnresolved') }}
</div>
</section>
<section v-show="viewTab === 'final'" class="panel">
<div class="panel-heading">
<h2>{{ t('sections.finalTitle') }}</h2>
<p>{{ t('sections.finalSubtitle') }}</p>
</div>
<div class="two-column">
<div>
<h3 class="sub-heading">{{ t('labels.finalGroup1') }}</h3>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.seed') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.score') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in finalGroup1" :key="'v-f1-' + row.playerId">
<td class="mono">{{ row.seed }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td class="mono strong">
<span>{{ finalTotalScore(row.playerId) }}</span>
<button
v-if="canViewProofs && finalProofStage(row.playerId)"
class="proof-mini"
@click="$emit('open-proof', { stage: finalProofStage(row.playerId), playerId: row.playerId })"
>
<img :src="scoreProofFor(finalProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
</button>
</td>
</tr>
<tr v-if="finalGroup1.length === 0"><td colspan="3" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody>
</table>
</div>
</div>
<div>
<h3 class="sub-heading">{{ t('labels.finalGroup2') }}</h3>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.seed') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.score') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in finalGroup2" :key="'v-f2-' + row.playerId">
<td class="mono">{{ row.seed }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td class="mono strong">
<span>{{ row.score }}</span>
<button
v-if="canViewProofs && finalProofStage(row.playerId)"
class="proof-mini"
@click="$emit('open-proof', { stage: finalProofStage(row.playerId), playerId: row.playerId })"
>
<img :src="scoreProofFor(finalProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
</button>
</td>
</tr>
<tr v-if="finalGroup2.length === 0"><td colspan="3" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<h3 class="sub-heading mt-32">{{ t('sections.finalRanking') }}</h3>
<div class="table-wrap">
<table class="score-table">
<thead>
<tr>
<th>{{ t('table.rank') }}</th>
<th>{{ t('table.competitor') }}</th>
<th>{{ t('table.score') }}</th>
<th>{{ t('table.tieScore') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in finalRows" :key="'v-fr-' + row.playerId" :class="{ 'podium-row': row.rank <= 3 }">
<td class="mono rank">{{ row.rank }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td class="mono strong">
<span>{{ row.score }}</span>
<button
v-if="canViewProofs && finalProofStage(row.playerId)"
class="proof-mini"
@click="$emit('open-proof', { stage: finalProofStage(row.playerId), playerId: row.playerId })"
>
<img :src="scoreProofFor(finalProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
</button>
</td>
<td class="mono">
<span>{{ row.tieBreak }}</span>
<button
v-if="canViewProofs && hasScoreProof('final_tiebreak', row.playerId)"
class="proof-mini"
@click="$emit('open-proof', { stage: 'final_tiebreak', playerId: row.playerId })"
>
<img :src="scoreProofFor('final_tiebreak', row.playerId)" :alt="t('table.verification')" />
</button>
</td>
</tr>
<tr v-if="finalRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
</tbody>
</table>
</div>
<div class="hint-box danger" v-if="finalTie.required && !finalTie.resolved">
{{ t('messages.finalTieUnresolved') }}
</div>
</section>
<section v-show="viewTab === 'podium'" class="panel podium-panel">
<div class="panel-heading">
<h2>{{ t('sections.podiumTitle') }}</h2>
<p>{{ t('sections.podiumSubtitle') }}</p>
</div>
<div class="podium-wrapper">
<article class="podium-col pos-2">
<div class="podium-avatar-wrap">
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
</div>
<div class="podium-medal-icon">🥈</div>
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
<div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
</div>
</article>
<article class="podium-col pos-1">
<div class="podium-avatar-wrap">
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
</div>
<div class="podium-medal-icon">🥇</div>
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
<div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
</div>
</article>
<article class="podium-col pos-3">
<div class="podium-avatar-wrap">
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
</div>
<div class="podium-medal-icon">🥉</div>
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
<div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
</div>
</article>
</div>
</section>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
t: { type: Function, required: true },
viewTabs: { type: Array, required: true },
viewTab: { type: String, required: true },
groupSummaries: { type: Array, required: true },
playersSorted: { type: Array, required: true },
rowClass: { type: Function, required: true },
playerImage: { type: Function, required: true },
displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true },
liveMode: { type: String, required: true },
selectedLiveGroup: { type: String, default: '' },
liveSelectableGroups: { type: Array, required: true },
liveGroupCode: { type: String, default: '' },
liveMembers: { type: Array, required: true },
liveTick: { type: Number, required: true },
preliminaryRows: { type: Array, required: true },
finalists: { type: Array, required: true },
isFinalist: { type: Function, required: true },
prelimTie: { type: Object, required: true },
finalGroup1: { type: Array, required: true },
finalGroup2: { type: Array, required: true },
finalRows: { type: Array, required: true },
finalTie: { type: Object, required: true },
finalTotalScore: { type: Function, required: true },
preliminaryProofStage: { type: Function, required: true },
finalProofStage: { type: Function, required: true },
podiumOrdered: { type: Array, required: true },
podiumImage: { type: Function, required: true },
podiumName: { type: Function, required: true },
podiumHasResult: { type: Function, required: true },
podiumScoreMain: { type: Function, required: true },
podiumTieSubtitle: { type: Function, required: true },
canViewProofs: { type: Boolean, default: false },
hasScoreProof: { type: Function, required: true },
scoreProofFor: { type: Function, required: true },
})
const groupedPlayers = computed(() => {
const map = new Map()
const unassigned = props.t('labels.unassigned')
for (const player of props.playersSorted) {
const code = (player.groupCode || '').trim() || unassigned
if (!map.has(code)) map.set(code, [])
map.get(code).push(player)
}
return [...map.entries()]
.sort((a, b) => {
if (a[0] === unassigned) return 1
if (b[0] === unassigned) return -1
return String(a[0]).localeCompare(String(b[0]), undefined, { numeric: true, sensitivity: 'base' })
})
.map(([code, players]) => ({
code,
key: resolveGroupKey(code, unassigned),
players: [...players].sort((p1, p2) => p1.id - p2.id),
}))
})
function resolveGroupKey(code, unassigned) {
if (code === unassigned) return 'u'
const normalized = String(code || '').trim().toUpperCase()
if (normalized.startsWith('A')) return 'a'
if (normalized.startsWith('B')) return 'b'
if (normalized.startsWith('C')) return 'c'
if (normalized.startsWith('D')) return 'd'
return 'u'
}
function groupLabel(code) {
const unassigned = props.t('labels.unassigned')
if (code === unassigned) return unassigned
return `${props.t('labels.group')} ${code}`
}
defineEmits(['change-tab', 'change-live-mode', 'change-live-group', 'open-proof'])
</script>

View File

@@ -0,0 +1,203 @@
<template>
<section class="live-settings-card">
<div class="live-settings-head">
<h3>{{ t('labels.liveModeVisibility') }}</h3>
</div>
<div class="live-settings-row">
<label class="control-label">{{ t('labels.activeLiveView') }}</label>
<select class="name-input" :value="liveSettings.activeView" @change="emitPatch({ activeView: $event.target.value })">
<option value="group_live">{{ t('labels.liveViewGroup') }}</option>
<option value="prelim_tie">{{ t('labels.liveViewPrelimTie') }}</option>
<option value="prelim_overall">{{ t('labels.liveViewPrelimOverall') }}</option>
<option value="final_groups">{{ t('labels.liveViewFinalGroups') }}</option>
<option value="final_tie">{{ t('labels.liveViewFinalTie') }}</option>
<option value="final_overall">{{ t('labels.liveViewFinalOverall') }}</option>
<option value="podium">{{ t('labels.liveViewPodium') }}</option>
</select>
</div>
<div class="live-settings-row">
<label class="control-label">{{ t('labels.groupDisplayMode') }}</label>
<div class="inline-settings">
<select class="name-input" :value="liveSettings.groupDisplayMode" @change="emitPatch({ groupDisplayMode: $event.target.value })">
<option value="rotate">{{ t('actions.liveModeRotate') }}</option>
<option value="fixed">{{ t('actions.liveModeFixed') }}</option>
</select>
<select
v-if="liveSettings.groupDisplayMode === 'fixed'"
class="name-input"
:value="liveSettings.groupFixedCode || ''"
@change="emitPatch({ groupFixedCode: $event.target.value })"
>
<option value="">{{ t('labels.unassigned') }}</option>
<option v-for="group in assignableGroups" :key="'live-fixed-group-' + group" :value="group">{{ group }}</option>
</select>
</div>
</div>
<div class="live-settings-row">
<label class="control-label">{{ t('labels.finalDisplayMode') }}</label>
<div class="inline-settings">
<select class="name-input" :value="liveSettings.finalDisplayMode" @change="emitPatch({ finalDisplayMode: $event.target.value })">
<option value="rotate">{{ t('actions.liveModeRotate') }}</option>
<option value="fixed">{{ t('actions.liveModeFixed') }}</option>
</select>
<select
v-if="liveSettings.finalDisplayMode === 'fixed'"
class="name-input"
:value="String(liveSettings.finalFixedGroup || 1)"
@change="emitPatch({ finalFixedGroup: Number($event.target.value) || 1 })"
>
<option value="1">1</option>
<option value="2">2</option>
</select>
</div>
</div>
<div class="live-settings-row">
<label class="control-label">{{ t('labels.rotationIntervalSec') }}</label>
<input
class="name-input"
type="number"
min="3"
max="30"
:value="liveSettings.rotationIntervalSec || 5"
@change="emitPatch({ rotationIntervalSec: Number($event.target.value) || 5 })"
/>
</div>
<div class="live-settings-row">
<label class="control-label">{{ t('labels.rotationPlayerCount') }}</label>
<input
class="name-input"
type="number"
min="3"
max="40"
:value="liveSettings.rotationPlayerCount || 12"
@change="emitPatch({ rotationPlayerCount: Number($event.target.value) || 12 })"
/>
</div>
<hr class="live-settings-divider" />
<div class="live-settings-row">
<label class="control-label">{{ t('labels.stageControl') }}</label>
<div class="stage-control-grid">
<div class="stage-select-card">
<p class="stage-select-label">{{ t('labels.stagePhase') }}</p>
<select class="name-input" :value="stageControl.phase" @change="updateStageControl({ phase: $event.target.value })">
<option value="preliminary">{{ t('labels.phasePreliminary') }}</option>
<option value="prelim_tiebreak">{{ t('labels.phasePrelimTie') }}</option>
<option value="final">{{ t('labels.phaseFinal') }}</option>
<option value="final_tiebreak">{{ t('labels.phaseFinalTie') }}</option>
</select>
</div>
<div class="stage-select-card">
<p class="stage-select-label">{{ t('labels.stageGroup') }}</p>
<select class="name-input" :value="stageControl.group" @change="updateStageControl({ group: $event.target.value })" :disabled="groupOptions.length === 0">
<option v-if="groupOptions.length === 0" value="">{{ t('messages.noGroupForPhase') }}</option>
<option v-for="group in groupOptions" :key="'stage-group-' + stageControl.phase + '-' + group" :value="group">{{ group }}</option>
</select>
</div>
<div class="stage-select-card">
<p class="stage-select-label">{{ t('labels.stageRound') }}</p>
<select class="name-input" :value="String(stageControl.round)" @change="updateStageControl({ round: Number($event.target.value) || 1 })">
<option v-for="round in roundOptions" :key="'stage-round-' + stageControl.phase + '-' + round" :value="String(round)">
{{ round }}
</option>
</select>
</div>
</div>
<p class="stage-selection-preview">{{ selectedStageSummary }}</p>
<div class="panel-actions compact stage-action-row">
<button class="btn btn-primary stage-start-btn" @click="$emit('start-stage')" :disabled="groupOptions.length === 0">
{{ t('actions.startStage') }}
</button>
<button class="btn btn-outline" @click="$emit('end-stage')">{{ t('actions.endStage') }}</button>
</div>
</div>
<div class="stage-status-grid">
<div class="stage-status-card">
<p class="stage-status-title">{{ t('labels.currentStage') }}</p>
<template v-if="currentStage">
<p class="stage-status-main">{{ formatStageMain(currentStage) }}</p>
<p class="stage-status-sub">{{ t('labels.startedAt') }}: {{ currentStage.startedAt || '-' }}</p>
<div class="panel-actions compact stage-status-actions">
<button class="btn btn-primary btn-xs" @click="$emit('navigate-current-stage')">{{ t('actions.goToStageScoring') }}</button>
</div>
</template>
<p v-else class="stage-status-empty">{{ t('labels.noCurrentStage') }}</p>
</div>
<div class="stage-status-card">
<p class="stage-status-title">{{ t('labels.lastStage') }}</p>
<template v-if="lastStage">
<p class="stage-status-main">{{ formatStageMain(lastStage) }}</p>
<p class="stage-status-sub">{{ t('labels.startedAt') }}: {{ lastStage.startedAt || '-' }}</p>
<p v-if="lastStage.endedAt" class="stage-status-sub">{{ t('labels.endedAt') }}: {{ lastStage.endedAt }}</p>
</template>
<p v-else class="stage-status-empty">{{ t('labels.noLastStage') }}</p>
</div>
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
t: { type: Function, required: true },
liveSettings: { type: Object, required: true },
assignableGroups: { type: Array, required: true },
stageControl: { type: Object, required: true },
stageOptions: { type: Object, required: true },
currentStage: { type: Object, default: null },
lastStage: { type: Object, default: null },
})
const emit = defineEmits(['update-live-setting', 'update-stage-control', 'start-stage', 'end-stage', 'navigate-current-stage'])
const groupOptions = computed(() => {
const key = String(props.stageControl.phase || '').trim().toLowerCase()
const list = props.stageOptions?.[key]
return Array.isArray(list) ? list : []
})
const roundOptions = computed(() => {
const phase = String(props.stageControl.phase || '').trim().toLowerCase()
if (phase === 'preliminary') return [1, 2, 3]
if (phase === 'final') return [1, 2]
return [1]
})
const selectedStageSummary = computed(() => {
const phase = formatPhase(props.stageControl.phase)
const group = String(props.stageControl.group || '').trim() || '-'
const round = Number(props.stageControl.round || 1)
return `${props.t('labels.stagePhase')}: ${phase} · ${props.t('labels.stageGroup')}: ${group} · ${props.t('labels.stageRound')}: ${round}`
})
function emitPatch(patch) {
emit('update-live-setting', patch)
}
function updateStageControl(patch) {
emit('update-stage-control', patch)
}
function formatPhase(phase) {
const normalized = String(phase || '').trim().toLowerCase()
if (normalized === 'preliminary') return props.t('labels.phasePreliminary')
if (normalized === 'prelim_tiebreak') return props.t('labels.phasePrelimTie')
if (normalized === 'final') return props.t('labels.phaseFinal')
if (normalized === 'final_tiebreak') return props.t('labels.phaseFinalTie')
return normalized || '-'
}
function formatStageMain(stage) {
if (!stage) return '-'
const group = String(stage.group || '').trim() || '-'
const round = Number(stage.round || 1)
return `${formatPhase(stage.phase)} · ${props.t('labels.stageGroup')} ${group} · ${props.t('labels.stageRound')} ${round}`
}
</script>

View File

@@ -0,0 +1,369 @@
<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
v-for="section in groupedSections"
:key="'multi-section-' + section.key"
class="score-section-block"
:class="{ 'score-section-overflow': isOverflow(section) }"
:style="section.style"
>
<button class="score-section-toggle" type="button" @click="toggleSection(section.key)">
<span class="score-section-title">{{ section.label }}</span>
<span class="score-section-meta">{{ section.rows.length }} {{ isCollapsed(section.key) ? '+' : '' }}</span>
</button>
<div v-if="isOverflow(section)" class="score-section-alert">{{ section.label }} > {{ overflowLimit }}</div>
<template v-if="!isCollapsed(section.key)">
<div class="table-wrap desktop-score-table">
<table class="score-table">
<thead>
<tr>
<th>#</th>
<th>{{ t('table.competitor') }}</th>
<th v-if="showGroup">{{ t('table.group') }}</th>
<th v-if="showRank">{{ t('table.rank') }}</th>
<th v-if="showSeed">{{ t('table.seed') }}</th>
<th
v-for="round in roundStages"
:key="'head-' + round.stage"
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
>
{{ round.label }}
</th>
<th>{{ t('table.total') }}</th>
<th v-if="showProofControls">{{ t('table.verification') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in section.rows" :key="'multi-row-' + section.key + '-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row, section)">
<td class="mono">{{ index + 1 }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td v-if="showGroup" class="mono">{{ groupCell(row) }}</td>
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
<td
v-for="round in roundStages"
:key="'cell-' + round.stage + '-' + row.playerId"
class="multi-round-cell"
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
>
<input
class="score-input score-input-compact"
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(round.stage, section) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(round.stage, row.playerId)"
@focus="onScoreFocus(round.stage, row.playerId)"
@input="onScoreInput(round.stage, row.playerId, $event)"
@blur="onScoreCommit(round.stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
/>
</td>
<td class="mono strong">{{ totalScore(row.playerId) }}</td>
<td v-if="showProofControls" class="verify-round-cell">
<div class="verify-round-list">
<div v-for="round in roundStages" :key="'verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
<span class="verify-round-label">{{ round.label }}</span>
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
</button>
<img
v-if="hasScoreProof(round.stage, row.playerId)"
class="proof-thumb"
:src="scoreProofFor(round.stage, row.playerId)"
:alt="t('table.verification')"
@click="openProofPreview(round.stage, row.playerId)"
/>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="mobile-score-cards">
<article v-for="(row, index) in section.rows" :key="'multi-mobile-' + section.key + '-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row, section)">
<div class="score-card-head">
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
<div class="mono">#{{ index + 1 }}</div>
</div>
<div class="score-card-meta">
<span v-if="showGroup">{{ t('table.group') }}: {{ groupCell(row) }}</span>
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
</div>
<div class="mobile-round-grid">
<label
v-for="round in roundStages"
:key="'mobile-round-' + round.stage + '-' + row.playerId"
class="mobile-round-item"
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
>
<span class="score-label">{{ round.label }}</span>
<input
class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(round.stage, section) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(round.stage, row.playerId)"
@focus="onScoreFocus(round.stage, row.playerId)"
@input="onScoreInput(round.stage, row.playerId, $event)"
@blur="onScoreCommit(round.stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
/>
</label>
</div>
<div class="mobile-total-line">
<span class="muted">{{ t('table.total') }}</span>
<strong class="mono">{{ totalScore(row.playerId) }}</strong>
</div>
<div v-if="showProofControls" class="mobile-verify-block">
<p class="mobile-verify-title">{{ t('table.verification') }}</p>
<div class="verify-round-list mobile">
<div v-for="round in roundStages" :key="'mobile-verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
<span class="verify-round-label">{{ round.label }}</span>
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
</button>
<img
v-if="hasScoreProof(round.stage, row.playerId)"
class="proof-thumb"
:src="scoreProofFor(round.stage, row.playerId)"
:alt="t('table.verification')"
@click="openProofPreview(round.stage, row.playerId)"
/>
</div>
</div>
</div>
</article>
</div>
</template>
</div>
<div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { groupThemeStyleForKey, scoreGroupStyle } from '../../utils/scoreGroupTheme'
import { normalizedGroupCode } from '../../utils/groups'
const props = defineProps({
t: { type: Function, required: true },
rows: { type: Array, required: true },
roundStages: { type: Array, required: true },
totalScore: { type: Function, required: true },
showFilter: { type: Boolean, default: true },
filterText: { type: String, default: '' },
showGroup: { type: Boolean, default: false },
showRank: { type: Boolean, default: false },
showSeed: { type: Boolean, default: false },
sectionByGroup: { type: Boolean, default: false },
overflowLimit: { type: Number, default: 0 },
showProofControls: { type: Boolean, default: true },
playerImage: { type: Function, required: true },
displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true },
scoreInputValue: { type: Function, required: true },
scoreInputInvalid: { type: Function, required: true },
onScoreFocus: { type: Function, required: true },
onScoreInput: { type: Function, required: true },
onScoreCommit: { type: Function, required: true },
hasScoreProof: { type: Function, required: true },
scoreProofFor: { type: Function, required: true },
openScoreProofUploader: { type: Function, required: true },
openProofPreview: { type: Function, required: true },
highlightStage: { type: String, default: '' },
highlightGroup: { type: String, default: '' },
})
defineEmits(['update:filter'])
const collapsedSections = ref({})
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 groupedSections = computed(() => {
if (!props.sectionByGroup) {
return [
{
key: 'all',
label: props.t('labels.allPlayers'),
rows: filteredRows.value,
style: null,
},
].filter((section) => section.rows.length > 0)
}
const buckets = new Map()
for (const row of filteredRows.value) {
const key = sectionKeyForRow(row)
if (!buckets.has(key)) {
buckets.set(key, {
key,
label: sectionLabelForKey(key),
rows: [],
})
}
buckets.get(key).rows.push(row)
}
return [...buckets.values()]
.sort((a, b) => sectionSortValue(a.key) - sectionSortValue(b.key) || String(a.key).localeCompare(String(b.key)))
.map((section) => ({
...section,
style: sectionStyleFor(section.key, section.rows[0]),
}))
})
watch(
groupedSections,
(sections) => {
const next = {}
for (const section of sections) {
next[section.key] = collapsedSections.value[section.key] || false
}
collapsedSections.value = next
},
{ immediate: true },
)
function sectionKeyForRow(row) {
const boardGroup = String(row?._boardGroupKey || '').trim()
if (boardGroup) return `B${boardGroup}`
const finalGroup = Number.parseInt(String(row?.finalGroup || ''), 10)
if (Number.isFinite(finalGroup) && finalGroup > 0) return `F${finalGroup}`
const group = normalizedGroupCode(row?.groupCode || '')
if (group) return `G${group}`
return 'U'
}
function sectionLabelForKey(key) {
if (key === 'U') return `${props.t('labels.group')} ${props.t('labels.unassigned')}`
if (key.startsWith('B')) return `${props.t('labels.group')} ${key.slice(1)}`
if (key.startsWith('F')) return `${props.t('labels.group')} ${key.slice(1)}`
if (key.startsWith('G')) return `${props.t('labels.group')} ${key.slice(1)}`
return `${props.t('labels.group')} ${key}`
}
function sectionSortValue(key) {
if (key === 'U') return 9999
if (key.startsWith('B')) {
const raw = key.slice(1)
const parsed = Number.parseInt(raw, 10)
if (Number.isFinite(parsed) && parsed > 0) return parsed
const code = String(raw).toUpperCase()
if (code === 'A') return 1
if (code === 'B') return 2
if (code === 'C') return 3
if (code === 'D') return 4
return 5000
}
if (key.startsWith('F')) return Number.parseInt(key.slice(1), 10) || 9998
if (key.startsWith('G')) {
const code = String(key.slice(1)).toUpperCase()
if (code === 'A') return 1
if (code === 'B') return 2
if (code === 'C') return 3
if (code === 'D') return 4
}
return 5000
}
function groupCell(row) {
if (row?._boardGroupKey) return row._boardGroupKey
if (row.finalGroup === 1 || row.finalGroup === 2) {
return row.finalGroup
}
return row.groupCode || props.t('labels.unassigned')
}
function sectionStyleFor(key, sampleRow) {
if (key.startsWith('B')) return groupThemeStyleForKey(key)
return scoreGroupStyle(sampleRow)
}
function scoreGroupStyleFor(row, section) {
if (section?.key?.startsWith('B') && section?.style) return section.style
return scoreGroupStyle(row)
}
function isCollapsed(sectionKey) {
return Boolean(collapsedSections.value[sectionKey])
}
function toggleSection(sectionKey) {
collapsedSections.value[sectionKey] = !collapsedSections.value[sectionKey]
}
function isOverflow(section) {
const limit = Number(props.overflowLimit || 0)
if (!Number.isFinite(limit) || limit <= 0) return false
return Number(section?.rows?.length || 0) > limit
}
function isHighlightedStage(stage, section) {
const stageMatch = String(props.highlightStage || '').trim().toLowerCase() === String(stage || '').trim().toLowerCase()
if (!stageMatch) return false
const wanted = String(props.highlightGroup || '').trim().toUpperCase()
if (!wanted) return true
return sectionGroupKey(section) === wanted
}
function sectionGroupKey(section) {
const key = String(section?.key || '').trim()
if (!key) return ''
if (key === 'U') return 'UNASSIGNED'
if (key.startsWith('B') || key.startsWith('F') || key.startsWith('G') || key.startsWith('T')) {
return String(key.slice(1)).trim().toUpperCase()
}
return key.toUpperCase()
}
</script>

View File

@@ -0,0 +1,286 @@
<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>
<div class="group-card-head-actions">
<span class="pm-count-badge mono">{{ group.players.length }}</span>
<button class="group-card-toggle" type="button" @click="toggleGroup(group.code)">
{{ isGroupCollapsed(group.code) ? '+' : '' }}
</button>
</div>
</header>
<div class="group-player-list" v-if="group.players.length > 0 && !isGroupCollapsed(group.code)">
<div class="group-player-row" v-for="player in group.players" :key="'card-player-' + player.id">
<div class="group-player-top">
<div class="competitor-cell">
<img :src="playerImage(player)" :alt="player.nameAr" class="competitor-image clickable" @click="$emit('open-image-uploader', player.id)" />
<div class="name-edit-grid vertical">
<input
class="name-input ar"
:value="player.nameAr"
:placeholder="t('table.arabicName')"
@blur="$emit('update-player-field', { player, field: 'nameAr', event: $event })"
/>
<input
class="name-input en"
dir="ltr"
:value="player.nameEn"
:placeholder="t('table.englishName')"
@blur="$emit('update-player-field', { player, field: 'nameEn', event: $event })"
/>
</div>
</div>
</div>
<div class="panel-actions compact name-convert-row">
<button class="btn btn-outline btn-xs" @click="$emit('convert-row-name', { player, direction: 'ar_to_en' })">{{ t('actions.convertArToEn') }}</button>
<button class="btn btn-outline btn-xs" @click="$emit('convert-row-name', { player, direction: 'en_to_ar' })">{{ t('actions.convertEnToAr') }}</button>
</div>
<div class="group-player-actions">
<select
class="name-input"
:value="normalizedGroupCode(player.groupCode)"
@change="$emit('update-player-group', { player, event: $event })"
>
<option value="">{{ groupOptionLabel('') }}</option>
<option v-for="item in assignableGroups" :key="'player-group-' + player.id + '-' + item" :value="item">
{{ groupOptionLabel(item) }}
</option>
</select>
<button class="btn btn-outline" @click="$emit('remove-player-image', player.id)">{{ t('actions.removeImage') }}</button>
<button class="btn btn-danger" @click="$emit('delete-player', player.id)">{{ t('actions.delete') }}</button>
</div>
</div>
</div>
<div v-else-if="group.players.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
</article>
</div>
</section>
</template>
<script setup>
import { computed, ref, watch } 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
})
const collapsedGroups = ref({})
watch(
groupedCards,
(cards) => {
const next = {}
for (const card of cards) {
next[card.code || 'U'] = collapsedGroups.value[card.code || 'U'] || false
}
collapsedGroups.value = next
},
{ immediate: true },
)
function isGroupCollapsed(code) {
return Boolean(collapsedGroups.value[code || 'U'])
}
function toggleGroup(code) {
const key = code || 'U'
collapsedGroups.value[key] = !collapsedGroups.value[key]
}
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>

View File

@@ -0,0 +1,249 @@
<template>
<div class="position-board">
<div class="position-board-head">
<button class="position-board-toggle" type="button" @click="collapsedAll = !collapsedAll">
<span class="position-board-title">{{ t('labels.groupAssignment') }}</span>
<span class="position-board-meta">{{ columns.length }} {{ collapsedAll ? '+' : '' }}</span>
</button>
<div v-if="hasOverflowColumns" class="position-board-alert">{{ t('labels.group') }} > 6</div>
</div>
<div v-if="!collapsedAll" class="position-board-grid">
<section
v-for="column in columns"
:key="board + '-col-' + column.key"
class="position-column"
:class="{ 'position-column-overflow': shouldHighlightOverflow && column.items.length > 6 }"
>
<header class="position-column-head">
<div class="position-column-toggle">
<span class="position-column-title">{{ columnLabel(column.key) }}</span>
<span class="position-column-count" :class="{ danger: shouldHighlightOverflow && column.items.length > 6 }">{{ column.items.length }}</span>
</div>
<div v-if="shouldHighlightOverflow && column.items.length > 6" class="position-column-alert">
{{ t('labels.group') }} {{ column.key }} > 6
</div>
</header>
<div class="position-column-list" @dragover.prevent @drop="onDropAtEnd(column.key)">
<article
v-for="(item, index) in column.items"
:key="board + '-card-' + item.playerId"
class="position-card"
draggable="true"
@dragstart="onDragStart($event, column.key, index, item.playerId)"
@dragover.prevent
@drop="onDropBefore(column.key, index)"
>
<div class="position-badge">{{ index + 1 }}</div>
<img :src="playerImage(item)" :alt="item.nameAr" class="position-avatar" />
<div class="position-card-text">
<p class="name-main">{{ displayName(item) }}</p>
<p class="name-sub">{{ secondaryName(item) }}</p>
<p v-if="scoreText(item)" class="position-score">{{ scoreText(item) }}</p>
</div>
</article>
<div v-if="column.items.length === 0" class="position-empty" @dragover.prevent @drop="onDropAtEnd(column.key)">
{{ t('labels.noPlayers') }}
</div>
</div>
</section>
</div>
<div class="position-actions" v-if="isNumericBoard">
<button class="btn btn-outline btn-xs" @click="addNumericGroup">+ {{ t('labels.group') }}</button>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
t: { type: Function, required: true },
board: { type: String, required: true },
rows: { type: Array, required: true },
slots: { type: Object, default: () => ({}) },
playerImage: { type: Function, required: true },
displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true },
groupLabelPrefix: { type: String, default: '' },
numericGroups: { type: Boolean, default: false },
availableGroups: { type: Array, default: () => [] },
})
const emit = defineEmits(['save'])
const columns = ref([])
const dragging = ref(null)
const collapsedAll = ref(false)
const isNumericBoard = computed(() => props.numericGroups)
const shouldHighlightOverflow = computed(
() =>
props.board === 'preliminary' ||
props.board === 'final' ||
props.board === 'prelim_tiebreak' ||
props.board === 'final_tiebreak',
)
const hasOverflowColumns = computed(
() => shouldHighlightOverflow.value && columns.value.some((column) => Number(column?.items?.length || 0) > 6),
)
watch(
() => `${props.board}|${props.rows.map((row) => row.playerId).join('|')}|${JSON.stringify(props.slots || {})}`,
() => {
rebuildColumns()
},
{ immediate: true },
)
function rebuildColumns() {
const mapped = (props.rows || []).map((row, idx) => {
const slot = props.slots?.[String(row.playerId)] || props.slots?.[row.playerId] || null
return {
...row,
slotGroup: normalizeGroup(slot?.groupKey, row, idx),
slotPosition: normalizePosition(slot?.position, idx + 1),
}
})
const byGroup = new Map()
for (const item of mapped) {
if (!byGroup.has(item.slotGroup)) byGroup.set(item.slotGroup, [])
byGroup.get(item.slotGroup).push(item)
}
const groupKeys = [...byGroup.keys()].sort(compareGroupKeys)
if (!isNumericBoard.value) {
for (const group of props.availableGroups || []) {
const normalized = String(group || '').trim().toUpperCase()
if (!normalized) continue
if (!groupKeys.includes(normalized)) groupKeys.push(normalized)
}
groupKeys.sort(compareGroupKeys)
}
let nextColumns = groupKeys.map((key) => {
const items = byGroup.get(key)
.slice()
.sort((a, b) => {
if (a.slotPosition !== b.slotPosition) return a.slotPosition - b.slotPosition
return a.playerId - b.playerId
})
return { key, items }
})
columns.value = nextColumns
}
function normalizeGroup(groupKey, row, idx) {
if (isNumericBoard.value) {
const parsed = Number.parseInt(String(groupKey || ''), 10)
if (Number.isFinite(parsed) && parsed > 0) return String(parsed)
const rowScoreGroup = Number.parseInt(String(row?.scoreGroup || ''), 10)
if (Number.isFinite(rowScoreGroup) && rowScoreGroup > 0) return String(rowScoreGroup)
const rowFinalGroup = Number.parseInt(String(row?.finalGroup || ''), 10)
if (Number.isFinite(rowFinalGroup) && rowFinalGroup > 0) return String(rowFinalGroup)
return '1'
}
const value = String(groupKey || row.groupCode || '').trim().toUpperCase()
return value || 'UNASSIGNED'
}
function normalizePosition(position, fallback) {
const parsed = Number(position)
if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed)
return fallback
}
function compareGroupKeys(a, b) {
if (isNumericBoard.value) {
return Number(a) - Number(b)
}
if (a === 'UNASSIGNED') return 1
if (b === 'UNASSIGNED') return -1
return a.localeCompare(b)
}
function columnLabel(key) {
if (isNumericBoard.value) {
return `${props.t('labels.group')} ${key}`
}
if (key === 'UNASSIGNED') return props.t('labels.unassigned')
return `${props.groupLabelPrefix || props.t('labels.group')} ${key}`
}
function scoreText(item) {
if (props.board === 'preliminary') return `${props.t('table.total')}: ${Number(item.score || 0)}`
if (props.board === 'final') return `${props.t('table.total')}: ${Number(item.score || 0)}`
return `${props.t('table.tieScore')}: ${Number(item.tieBreak || 0)}`
}
function onDragStart(event, groupKey, index, playerId) {
dragging.value = { groupKey, index, playerId }
event.dataTransfer.effectAllowed = 'move'
}
function onDropBefore(targetGroupKey, targetIndex) {
moveDragged(targetGroupKey, targetIndex)
}
function onDropAtEnd(targetGroupKey) {
const column = columns.value.find((entry) => entry.key === targetGroupKey)
const targetIndex = column ? column.items.length : 0
moveDragged(targetGroupKey, targetIndex)
}
function moveDragged(targetGroupKey, targetIndex) {
const drag = dragging.value
if (!drag) return
const sourceCol = columns.value.find((entry) => entry.key === drag.groupKey)
const targetCol = columns.value.find((entry) => entry.key === targetGroupKey)
if (!sourceCol || !targetCol) {
dragging.value = null
return
}
const sourceIndex = sourceCol.items.findIndex((item) => item.playerId === drag.playerId)
if (sourceIndex < 0) {
dragging.value = null
return
}
const [moved] = sourceCol.items.splice(sourceIndex, 1)
let insertAt = targetIndex
if (sourceCol.key === targetCol.key && sourceIndex < targetIndex) {
insertAt -= 1
}
if (insertAt < 0) insertAt = 0
if (insertAt > targetCol.items.length) insertAt = targetCol.items.length
targetCol.items.splice(insertAt, 0, moved)
dragging.value = null
emitSave()
}
function emitSave() {
const slots = []
for (const column of columns.value) {
column.items.forEach((item, idx) => {
slots.push({
playerId: item.playerId,
groupKey: column.key,
position: idx + 1,
})
})
}
emit('save', { board: props.board, slots })
}
function addNumericGroup() {
if (!isNumericBoard.value) return
const keys = columns.value.map((entry) => Number(entry.key)).filter((value) => Number.isFinite(value) && value > 0)
const next = keys.length > 0 ? Math.max(...keys) + 1 : 1
columns.value = [...columns.value, { key: String(next), items: [] }]
}
</script>

View File

@@ -0,0 +1,340 @@
<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 v-for="section in groupedSections" :key="'section-' + stage + '-' + section.key" class="score-section-block" :class="{ 'tie-break': tieBreakSectionMode }" :style="section.style">
<button class="score-section-toggle" type="button" @click="toggleSection(section.key)">
<span class="score-section-title">{{ section.label }}</span>
<span class="score-section-meta">{{ section.rows.length }} {{ isCollapsed(section.key) ? '+' : '' }}</span>
</button>
<template v-if="!isCollapsed(section.key)">
<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 :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">{{ inputLabel }}</th>
<th v-if="showProofControls">{{ t('table.verification') }}</th>
<th v-if="showRank">{{ t('table.rank') }}</th>
<th v-if="showSeed">{{ t('table.seed') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in section.rows"
:key="'desk-' + stage + '-' + section.key + '-' + row.playerId"
class="score-group-row"
:style="scoreRowStyleFor(row, section)"
>
<td class="mono">{{ index + 1 }}</td>
<td>
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td v-if="showGroup" class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
<td v-if="showScoreBeforeInput" class="mono strong">{{ row.score }}</td>
<td :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">
<input
class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(stage, section) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
: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 v-if="showRank" class="mono rank">{{ row.rank }}</td>
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
</tr>
</tbody>
</table>
</div>
<div class="mobile-score-cards">
<article
v-for="(row, index) in section.rows"
:key="'mob-' + stage + '-' + section.key + '-' + row.playerId"
class="score-card score-group-card"
:style="scoreRowStyleFor(row, section)"
>
<div class="score-card-head">
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
<div class="mono">#{{ index + 1 }}</div>
</div>
<div class="score-card-meta">
<span v-if="showGroup">{{ t('table.group') }}: {{ row.groupCode || t('labels.unassigned') }}</span>
<span v-if="showScoreBeforeInput">{{ t('table.score') }}: {{ row.score }}</span>
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
</div>
<label class="score-label" :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">{{ inputLabel }}</label>
<input
class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(stage, section) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
: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 v-if="showProofControls" 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-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>
</template>
</div>
<div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { groupThemeStyleForKey, scoreGroupStyle, scoreValueStyle } from '../../utils/scoreGroupTheme'
import { normalizedGroupCode } from '../../utils/groups'
const props = defineProps({
t: { type: Function, required: true },
stage: { type: String, required: true },
rows: { type: Array, required: true },
showFilter: { type: Boolean, default: true },
filterText: { type: String, default: '' },
showGroup: { type: Boolean, default: false },
showRank: { type: Boolean, default: false },
showSeed: { type: Boolean, default: false },
colorMode: { type: String, default: 'group' },
showScoreBeforeInput: { type: Boolean, default: false },
inputLabel: { type: String, required: true },
sectionByGroup: { type: Boolean, default: false },
tieBreakSectionMode: { type: Boolean, default: false },
showProofControls: { type: Boolean, default: true },
playerImage: { type: Function, required: true },
displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true },
scoreInputValue: { type: Function, required: true },
scoreInputInvalid: { 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 },
highlightStage: { type: String, default: '' },
highlightGroup: { type: String, default: '' },
})
defineEmits(['update:filter'])
const collapsedSections = ref({})
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 groupedSections = computed(() => {
if (!props.sectionByGroup) {
return [
{
key: 'all',
label: props.t('labels.allPlayers'),
rows: filteredRows.value,
style: null,
},
].filter((section) => section.rows.length > 0)
}
const buckets = new Map()
for (const row of filteredRows.value) {
const key = sectionKeyForRow(row)
if (!buckets.has(key)) {
buckets.set(key, {
key,
label: sectionLabelForKey(key),
rows: [],
})
}
buckets.get(key).rows.push(row)
}
return [...buckets.values()]
.sort((a, b) => sectionSortValue(a.key) - sectionSortValue(b.key) || String(a.key).localeCompare(String(b.key)))
.map((section) => ({
...section,
style: sectionStyleFor(section.key, section.rows[0]),
}))
})
watch(
groupedSections,
(sections) => {
const next = {}
for (const section of sections) {
next[section.key] = collapsedSections.value[section.key] || false
}
collapsedSections.value = next
},
{ immediate: true },
)
function sectionKeyForRow(row) {
const boardGroup = String(row?._boardGroupKey || '').trim()
if (boardGroup) return `B${boardGroup}`
if (props.tieBreakSectionMode) {
const tieGroup = Number.parseInt(String(row?.scoreGroup || ''), 10)
if (Number.isFinite(tieGroup) && tieGroup > 0) return `T${tieGroup}`
}
const finalGroup = Number.parseInt(String(row?.finalGroup || ''), 10)
if (Number.isFinite(finalGroup) && finalGroup > 0) return `F${finalGroup}`
const group = normalizedGroupCode(row?.groupCode || '')
if (group) return `G${group}`
return 'U'
}
function sectionLabelForKey(key) {
if (key === 'U') return `${props.t('labels.group')} ${props.t('labels.unassigned')}`
if (key.startsWith('B')) return `${props.t('labels.group')} ${key.slice(1)}`
if (key.startsWith('T')) return `${props.t('labels.group')} ${key.slice(1)}`
if (key.startsWith('F')) return `${props.t('labels.group')} ${key.slice(1)}`
if (key.startsWith('G')) return `${props.t('labels.group')} ${key.slice(1)}`
return `${props.t('labels.group')} ${key}`
}
function sectionSortValue(key) {
if (key === 'U') return 9999
if (key.startsWith('B')) {
const raw = key.slice(1)
const parsed = Number.parseInt(raw, 10)
if (Number.isFinite(parsed) && parsed > 0) return parsed
const code = String(raw).toUpperCase()
if (code === 'A') return 1
if (code === 'B') return 2
if (code === 'C') return 3
if (code === 'D') return 4
return 5000 + hashToHue(code)
}
if (key.startsWith('T') || key.startsWith('F')) return Number.parseInt(key.slice(1), 10) || 9998
if (key.startsWith('G')) {
const raw = key.slice(1)
const code = String(raw || '').trim().toUpperCase()
if (code === 'A') return 1
if (code === 'B') return 2
if (code === 'C') return 3
if (code === 'D') return 4
return 5000 + hashToHue(code)
}
return 8000 + hashToHue(key)
}
function sectionStyleFor(key, sampleRow) {
if (key.startsWith('B')) return groupThemeStyleForKey(key)
if (props.tieBreakSectionMode) {
return groupThemeStyleForKey(key, { tieBreak: true })
}
return scoreGroupStyle(sampleRow)
}
function scoreRowStyleFor(row, section) {
if (section?.key?.startsWith('B') && section?.style) return section.style
if (props.tieBreakSectionMode && section?.style) return section.style
if (props.colorMode === 'score') return scoreValueStyle(row?.score)
return scoreGroupStyle(row)
}
function isCollapsed(sectionKey) {
return Boolean(collapsedSections.value[sectionKey])
}
function toggleSection(sectionKey) {
collapsedSections.value[sectionKey] = !collapsedSections.value[sectionKey]
}
function isHighlightedStage(stageName, section) {
const stageMatch = String(props.highlightStage || '').trim().toLowerCase() === String(stageName || '').trim().toLowerCase()
if (!stageMatch) return false
const wanted = String(props.highlightGroup || '').trim().toUpperCase()
if (!wanted) return true
return sectionGroupKey(section) === wanted
}
function sectionGroupKey(section) {
const key = String(section?.key || '').trim()
if (!key) return ''
if (key === 'U') return 'UNASSIGNED'
if (key.startsWith('B') || key.startsWith('F') || key.startsWith('G') || key.startsWith('T')) {
return String(key.slice(1)).trim().toUpperCase()
}
return key.toUpperCase()
}
function hashToHue(value) {
const input = String(value || 'U')
let hash = 0
for (let i = 0; i < input.length; i += 1) {
hash = (hash * 33 + input.charCodeAt(i)) % 360
}
return (hash + 360) % 360
}
</script>

View File

@@ -0,0 +1,455 @@
export const I18N = {
ar: {
titleFallback: 'بطولة دويتوايلر للرماية',
subtitle: 'فئة المسدس · مسافات: 15م ← 20م ← 25م',
viewMode: 'وضع العرض',
adminMode: 'لوحة الإدارة',
liveMode: 'الوضع الحي',
adminLogin: 'تسجيل دخول الإدارة',
adminLoginDesc: 'أدخل بيانات الإدارة للتحكم الكامل في البطولة.',
adminPanel: 'لوحة التحكم الإدارية',
adminPanelDesc: 'إدارة اللاعبين، التوزيع، وإدخال النتائج لكل المراحل.',
defaultCredentials: 'بيانات الإدارة الافتراضية: datwyler / datwyler',
labels: {
liveTracker: 'LIVE TRACKER',
mode: 'الوضع',
language: 'اللغة',
fullscreen: 'ملء الشاشة',
lastSync: 'آخر مزامنة',
loading: 'جاري تحميل بيانات البطولة...',
players: 'لاعب',
group: 'المجموعة',
unassigned: 'غير معين',
emptyLive: 'لا يوجد لاعبين في هذه المجموعة',
allPlayers: 'جميع اللاعبين',
top12: 'أفضل 12 متأهل',
finalist: 'متأهل',
tieBreak: 'كسر تعادل',
notFinalist: 'غير متأهل',
noPlayers: 'لا يوجد لاعبين بعد',
noFinalists: 'لا يوجد متأهلون بعد',
finalGroup1: 'المجموعة النهائية 1 (المراكز 1-6)',
finalGroup2: 'المجموعة النهائية 2 (المراكز 7-12)',
finalGroupSeeds1: 'المجموعة النهائية (المراكز 1-6)',
finalGroupSeeds2: 'المجموعة النهائية (المراكز 7-12)',
tieSlots: 'عدد المقاعد المتاحة من كسر التعادل',
waiting: 'بانتظار النتيجة',
viewProofInView: 'إظهار صور إثبات النتيجة في وضع العرض',
liveModeVisibility: 'إعدادات شاشة الوضع الحي',
activeLiveView: 'العرض النشط في الوضع الحي',
rotationIntervalSec: 'مدة التدوير (ثانية)',
rotationPlayerCount: 'عدد اللاعبين لكل صفحة تدوير',
liveViewGroup: 'شاشة المجموعات الحية',
liveViewPrelimTie: 'كسر تعادل التمهيدي',
liveViewPrelimOverall: 'الترتيب العام للتمهيدي',
liveViewFinalGroups: 'شاشة مجموعات النهائي',
liveViewFinalTie: 'كسر تعادل النهائي',
liveViewFinalOverall: 'الترتيب العام للنهائي',
liveViewPodium: 'منصة التتويج',
showGroupLive: 'إظهار شاشة المجموعات الحية',
showPrelimTie: 'إظهار كسر تعادل التمهيدي',
showPrelimOverall: 'إظهار ترتيب التمهيدي العام',
showFinalGroups: 'إظهار مجموعات النهائي',
showFinalTie: 'إظهار كسر تعادل النهائي',
showPodium: 'إظهار منصة التتويج',
groupDisplayMode: 'عرض المجموعات',
finalDisplayMode: 'عرض مجموعات النهائي',
fixedGroup: 'مجموعة ثابتة',
fixedFinalGroup: 'المجموعة النهائية الثابتة',
sortBy: 'الترتيب',
zoom: 'التكبير',
currentScore: 'النتيجة الحالية',
aiSuggestedScore: 'النتيجة المقترحة',
confidence: 'مستوى الثقة',
stageControl: 'المرحلة الحالية',
stagePhase: 'الطور',
stageGroup: 'المجموعة',
stageRound: 'الجولة',
currentStage: 'المرحلة الجارية',
lastStage: 'آخر مرحلة',
startedAt: 'بدأت',
endedAt: 'انتهت',
noCurrentStage: 'لا توجد مرحلة جارية',
noLastStage: 'لا توجد مرحلة سابقة',
phasePreliminary: 'التمهيدي',
phasePrelimTie: 'كسر تعادل التمهيدي',
phaseFinal: 'النهائي',
phaseFinalTie: 'كسر تعادل النهائي',
groupAssignment: 'توزيع المجموعات',
},
sections: {
groupsTitle: 'عرض اللاعبين والمجموعات',
groupsSubtitle: 'شاشة نظيفة للمتابعة المباشرة حسب المجموعة.',
liveTitle: 'العرض الحي للمجموعة',
liveSubtitle: 'تدوير تلقائي بين المجموعات المسجلة كل 5 ثوان.',
overallTitle: 'الترتيب العام للمرحلة التمهيدية',
overallSubtitle: 'يتم تمييز أفضل 12 متأهل للنهائي.',
finalTitle: 'المرحلة النهائية',
finalSubtitle: 'تقسيم المتأهلين إلى مجموعتين حسب الترتيب.',
finalRanking: 'الترتيب النهائي',
podiumTitle: 'منصة التتويج',
podiumSubtitle: 'المراكز الثلاثة الأولى بعد فك أي تعادل.',
playersTitle: 'إدارة اللاعبين',
playersSubtitle: 'إضافة/تعديل/حذف لاعب، وتعديل الاسم والصورة والمجموعة.',
groupsConfigPlaceholder: 'المجموعات الأساسية (مثال: A,B,C,D)',
preliminaryAdminTitle: 'إدخال نتائج المرحلة التمهيدية',
preliminaryAdminSubtitle: 'إدخال يدوي للنتيجة التمهيدية لكل لاعب.',
prelimTieTitle: 'إدخال كسر تعادل التأهل (أفضل 12)',
prelimTieSubtitle: 'يظهر فقط اللاعبين المتعادلين على حد التأهل.',
finalAdminTitle: 'إدخال نتائج المرحلة النهائية',
finalAdminSubtitle: 'إدخال نتائج النهائي للمجموعتين.',
finalTieTitle: 'إدخال كسر تعادل منصة التتويج',
finalTieSubtitle: 'يظهر فقط عند تعادل المراكز 1-3.',
profileCropTitle: 'تعديل صورة اللاعب',
profileCropSubtitle: 'حرّك وكبّر الصورة لتناسب الإطار الدائري قبل الحفظ.',
aiAdvisorTitle: 'مساعد الذكاء الاصطناعي للنتيجة',
liveModeTitle: 'شاشة البطولة المباشرة',
liveModeSubtitle: 'عرض مخصص للشاشات أثناء الفعالية مع تحديث لحظي.',
prelimRoundsTitle: 'الجولات التمهيدية',
finalRoundsTitle: 'جولات النهائي',
},
table: {
competitor: 'اللاعب',
group: 'المجموعة',
rank: 'الترتيب',
position: 'الموضع',
score: 'النتيجة',
total: 'المجموع',
round1: 'جولة 1',
round2: 'جولة 2',
round3: 'جولة 3',
tieScore: 'نتيجة كسر التعادل',
verification: 'التحقق',
status: 'الحالة',
seed: 'التصنيف',
medal: 'الميدالية',
actions: 'الإجراءات',
arabicName: 'الاسم بالعربية',
englishName: 'الاسم بالإنجليزية',
},
actions: {
refresh: 'تحديث',
login: 'دخول',
logout: 'تسجيل خروج',
updateGroups: 'تحديث المجموعات',
liveRotate: 'دوران تلقائي',
liveFixed: 'مجموعة محددة',
uploadProof: 'رفع إثبات',
replaceProof: 'استبدال الإثبات',
removeProof: 'حذف الإثبات',
addPlayer: 'إضافة لاعب',
removeImage: 'حذف الصورة',
delete: 'حذف',
resetScores: 'تصفير نتائج المرحلة',
resetFinalGroupBySeed: 'إعادة مجموعات النهائي حسب التصنيف (1-6 / 7-12)',
resetOnlyScores: 'تصفير النتائج فقط',
resetScoresAndProofs: 'تصفير النتائج وحذف الإثبات',
randomEvenGroups: 'توزيع عشوائي متوازن',
searchPlayer: 'بحث بالاسم العربي أو الإنجليزي',
cancel: 'إلغاء',
convertArToEn: 'تحويل عربي → إنجليزي',
convertEnToAr: 'تحويل إنجليزي → عربي',
sortById: 'حسب الرقم',
sortByArabic: 'حسب الاسم العربي',
sortByEnglish: 'حسب الاسم الإنجليزي',
sortByGroup: 'حسب المجموعة',
aiAdvisor: 'اقتراح AI',
quickApplyAi: 'تطبيق سريع AI',
reAnalyze: 'إعادة التحليل',
applySuggestedScore: 'اعتماد النتيجة المقترحة',
applyCrop: 'تطبيق القص',
resetPosition: 'إعادة الضبط',
liveModeRotate: 'تدوير تلقائي',
liveModeFixed: 'ثابت',
enterFullscreen: 'دخول ملء الشاشة',
exitFullscreen: 'الخروج من ملء الشاشة',
startStage: 'بدء المرحلة',
endStage: 'إنهاء المرحلة',
goToStageScoring: 'الانتقال إلى إدخال نتائج المرحلة',
},
auth: {
username: 'اسم المستخدم',
password: 'كلمة المرور',
},
tabs: {
groups: 'المجموعات',
live: 'عرض حي',
overall: 'الترتيب العام',
final: 'النهائي',
podium: 'التتويج',
players: 'اللاعبون',
preliminary: 'التمهيدي',
prelimTie: 'كسر تعادل التأهل',
finalTie: 'كسر تعادل التتويج',
liveModeAdmin: 'إعدادات الوضع الحي',
},
messages: {
saved: 'تم الحفظ بنجاح.',
mustProvideNames: 'يرجى إدخال الاسم بالعربية والإنجليزية.',
noPrelimTie: 'لا يوجد تعادل على حد التأهل.',
noFinalTie: 'لا يوجد تعادل في المراكز 1-3.',
prelimTieUnresolved: 'تعادل التأهل غير محسوم. أدخل نتائج كسر التعادل لتحديد أفضل 12.',
finalTieUnresolved: 'تعادل منصة التتويج غير محسوم. أكمل إدخال كسر التعادل.',
confirmDelete: 'هل تريد حذف اللاعب؟',
confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟',
resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟',
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 100.',
unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.',
errorPrefix: 'حدث خطأ',
noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.',
noNameToConvert: 'لا يوجد اسم للتحويل.',
aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...',
noProofForAi: 'لا توجد صورة إثبات لتحليلها.',
noGroupForPhase: 'لا توجد مجموعات متاحة لهذا الطور.',
},
},
en: {
titleFallback: 'Datwyler Shooting Event',
subtitle: 'Pistol class · Distances: 15m ← 20m ← 25m',
viewMode: 'View Mode',
adminMode: 'Admin Panel',
liveMode: 'Live Mode',
adminLogin: 'Admin Login',
adminLoginDesc: 'Enter admin credentials for full tournament control.',
adminPanel: 'Admin Control Panel',
adminPanelDesc: 'Manage players, assignments, and scoring for all stages.',
defaultCredentials: 'Default admin credentials: datwyler / datwyler',
labels: {
liveTracker: 'LIVE TRACKER',
mode: 'Mode',
language: 'Language',
fullscreen: 'Fullscreen',
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',
tieBreak: 'Tie-break',
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)',
finalGroupSeeds1: 'Final Group Seeds 1-6',
finalGroupSeeds2: 'Final Group Seeds 7-12',
tieSlots: 'Tie-break slots',
waiting: 'Waiting',
viewProofInView: 'Allow proof images in view mode',
liveModeVisibility: 'Live mode screen controls',
activeLiveView: 'Active live view',
rotationIntervalSec: 'Rotation interval (sec)',
rotationPlayerCount: 'Players per rotation page',
liveViewGroup: 'Live group board',
liveViewPrelimTie: 'Preliminary tie-break',
liveViewPrelimOverall: 'Preliminary overall',
liveViewFinalGroups: 'Final groups board',
liveViewFinalTie: 'Final tie-break',
liveViewFinalOverall: 'Final overall ranking',
liveViewPodium: 'Podium',
showGroupLive: 'Show live group board',
showPrelimTie: 'Show preliminary tie-break',
showPrelimOverall: 'Show preliminary overall ranking',
showFinalGroups: 'Show final groups board',
showFinalTie: 'Show final tie-break',
showPodium: 'Show podium',
groupDisplayMode: 'Group board mode',
finalDisplayMode: 'Final board mode',
fixedGroup: 'Fixed group',
fixedFinalGroup: 'Fixed final group',
sortBy: 'Sort by',
zoom: 'Zoom',
currentScore: 'Current score',
aiSuggestedScore: 'AI suggested score',
confidence: 'Confidence',
stageControl: 'Current Stage Control',
stagePhase: 'Phase',
stageGroup: 'Group',
stageRound: 'Round',
currentStage: 'Current Stage',
lastStage: 'Last Stage',
startedAt: 'Started',
endedAt: 'Ended',
noCurrentStage: 'No active stage',
noLastStage: 'No previous stage',
phasePreliminary: 'Preliminary',
phasePrelimTie: 'Preliminary Tie-Break',
phaseFinal: 'Final',
phaseFinalTie: 'Final Tie-Break',
groupAssignment: 'Group Assignment',
},
sections: {
groupsTitle: 'Players & Groups Overview',
groupsSubtitle: 'Clean view screen for live grouping.',
liveTitle: 'Live Group Screen',
liveSubtitle: 'Automatic rotation through registered groups every 5 seconds.',
overallTitle: 'Preliminary Overall Ranking',
overallSubtitle: 'Top 12 finalists are highlighted.',
finalTitle: 'Final Stage',
finalSubtitle: 'Finalists are split into two groups by rank.',
finalRanking: 'Final Ranking',
podiumTitle: 'Podium',
podiumSubtitle: 'Top 3 after tie-break resolution.',
playersTitle: 'Players Management',
playersSubtitle: 'Add/update/remove players, names, images, and group assignment.',
groupsConfigPlaceholder: 'Primary groups (example: A,B,C,D)',
preliminaryAdminTitle: 'Preliminary Scoring',
preliminaryAdminSubtitle: 'Manually input each player preliminary score.',
prelimTieTitle: 'Qualification Tie-Break Scoring (Top 12)',
prelimTieSubtitle: 'Only players tied at qualification cutoff are shown.',
finalAdminTitle: 'Final Stage Scoring',
finalAdminSubtitle: 'Input final scores for both final groups.',
finalTieTitle: 'Podium Tie-Break Scoring',
finalTieSubtitle: 'Shown only when places 1-3 are tied.',
profileCropTitle: 'Adjust Player Photo',
profileCropSubtitle: 'Move and zoom to fit the circular avatar before saving.',
aiAdvisorTitle: 'AI Score Advisor',
liveModeTitle: 'Event Live Screen',
liveModeSubtitle: 'Monitor-ready display with real-time updates.',
prelimRoundsTitle: 'Preliminary Rounds',
finalRoundsTitle: 'Final Rounds',
},
table: {
competitor: 'Player',
group: 'Group',
rank: 'Rank',
position: 'Position',
score: 'Score',
total: 'Total',
round1: 'Round 1',
round2: 'Round 2',
round3: 'Round 3',
tieScore: 'Tie-Break Score',
verification: 'Verification',
status: 'Status',
seed: 'Seed',
medal: 'Medal',
actions: 'Actions',
arabicName: 'Arabic Name',
englishName: 'English Name',
},
actions: {
refresh: 'Refresh',
login: 'Login',
logout: 'Logout',
updateGroups: 'Update Groups',
liveRotate: 'Auto Rotation',
liveFixed: 'Fixed Group',
uploadProof: 'Upload Proof',
replaceProof: 'Replace Proof',
removeProof: 'Remove Proof',
addPlayer: 'Add Player',
removeImage: 'Remove Image',
delete: 'Delete',
resetScores: 'Reset Stage Scores',
resetFinalGroupBySeed: 'Reset Final Groups By Seed (1-6 / 7-12)',
resetOnlyScores: 'Reset scores only',
resetScoresAndProofs: 'Reset scores and proofs',
randomEvenGroups: 'Random even grouping',
searchPlayer: 'Search by Arabic or English name',
cancel: 'Cancel',
convertArToEn: 'Convert Arabic → English',
convertEnToAr: 'Convert English → Arabic',
sortById: 'By ID',
sortByArabic: 'By Arabic name',
sortByEnglish: 'By English name',
sortByGroup: 'By group',
aiAdvisor: 'AI Advice',
quickApplyAi: 'Quick Apply AI',
reAnalyze: 'Re-analyze',
applySuggestedScore: 'Apply suggested score',
applyCrop: 'Apply crop',
resetPosition: 'Reset position',
liveModeRotate: 'Auto rotate',
liveModeFixed: 'Fixed',
enterFullscreen: 'Enter Fullscreen',
exitFullscreen: 'Exit Fullscreen',
startStage: 'Start Stage',
endStage: 'End Stage',
goToStageScoring: 'Go To Stage Scoring',
},
auth: {
username: 'Username',
password: 'Password',
},
tabs: {
groups: 'Groups',
live: 'Live',
overall: 'Overall',
final: 'Final',
podium: 'Podium',
players: 'Players',
preliminary: 'Preliminary',
prelimTie: 'Prelim Tie-Break',
finalTie: 'Final Tie-Break',
liveModeAdmin: 'Live Mode Settings',
},
messages: {
saved: 'Saved successfully.',
mustProvideNames: 'Arabic and English names are required.',
noPrelimTie: 'No tie at qualification cutoff.',
noFinalTie: 'No tie in places 1-3.',
prelimTieUnresolved: 'Qualification tie is unresolved. Enter tie-break scores to finalize top 12.',
finalTieUnresolved: 'Podium tie is unresolved. Complete tie-break scoring.',
confirmDelete: 'Delete this player?',
confirmReset: 'Reset this stage scores?',
resetProofPrompt: 'Also remove all proof images for this stage?',
invalidScore: 'Score must be between 0 and 100.',
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.',
noGroupForPhase: 'No groups available for this phase.',
},
},
}
export function createInitialState() {
return {
competition: { titleAr: '', titleEn: '' },
players: [],
scores: {
prelim_r1: {},
prelim_r2: {},
prelim_r3: {},
prelim_tiebreak: {},
final_r1: {},
final_r2: {},
final_tiebreak: {},
},
scoreProofs: {},
settings: {
viewProofInView: false,
liveMode: {
activeView: 'group_live',
showGroupLive: true,
groupDisplayMode: 'rotate',
groupFixedCode: '',
showPrelimTie: true,
showPrelimOverall: true,
showFinalGroups: true,
finalDisplayMode: 'rotate',
finalFixedGroup: 1,
showFinalTie: true,
showPodium: true,
rotationIntervalSec: 5,
rotationPlayerCount: 12,
},
},
derived: {
preliminaryRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
finalists: [],
finalGroups: { group1: [], group2: [] },
finalRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
podium: [],
},
current_stage: null,
last_stage: null,
serverTime: '',
}
}

5
frontend/src/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

3337
frontend/src/style.css Normal file

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -0,0 +1,83 @@
import { normalizedGroupCode } from './groups'
const PRESET = {
A: { accent: '#0d1931', soft: 'rgba(13, 25, 49, 0.14)', border: 'rgba(13, 25, 49, 0.44)' },
B: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' },
C: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' },
D: { accent: '#1b325f', soft: 'rgba(27, 50, 95, 0.14)', border: 'rgba(27, 50, 95, 0.42)' },
F1: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' },
F2: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' },
U: { accent: '#4c5f86', soft: 'rgba(76, 95, 134, 0.14)', border: 'rgba(76, 95, 134, 0.4)' },
}
const THEME_PALETTE = [
{ accent: '#0d1931', soft: 'rgba(13, 25, 49, 0.15)', border: 'rgba(13, 25, 49, 0.46)' },
{ accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.15)', border: 'rgba(0, 140, 168, 0.46)' },
{ accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.15)', border: 'rgba(237, 46, 35, 0.46)' },
{ accent: '#1b325f', soft: 'rgba(27, 50, 95, 0.15)', border: 'rgba(27, 50, 95, 0.44)' },
{ accent: '#0d5f72', soft: 'rgba(13, 95, 114, 0.15)', border: 'rgba(13, 95, 114, 0.44)' },
{ accent: '#b8261d', soft: 'rgba(184, 38, 29, 0.15)', border: 'rgba(184, 38, 29, 0.44)' },
]
export function scoreGroupToken(row) {
const finalGroup = Number(row?.finalGroup || 0)
if (finalGroup === 1) return 'F1'
if (finalGroup === 2) return 'F2'
const code = normalizedGroupCode(row?.groupCode || '')
if (code) return code
return 'U'
}
export function scoreGroupStyle(row) {
const token = scoreGroupToken(row)
const preset = PRESET[token]
if (preset) {
return {
'--score-group-accent': preset.accent,
'--score-group-soft': preset.soft,
'--score-group-border': preset.border,
}
}
return groupThemeStyleForKey(token)
}
export function scoreValueStyle(scoreValue) {
const score = Number(scoreValue || 0)
const normalized = Number.isFinite(score) ? score : 0
const hue = hashToHue(`score:${normalized}`)
return {
'--score-group-accent': `hsl(${hue} 70% 38%)`,
'--score-group-soft': `hsla(${hue}, 72%, 46%, 0.10)`,
'--score-group-border': `hsla(${hue}, 70%, 38%, 0.34)`,
}
}
export function groupThemeStyleForKey(key, options = {}) {
const raw = String(key || '').trim().toUpperCase()
const compact = raw.replace(/^[A-Z]/, '')
const numeric = Number.parseInt(compact || raw, 10)
const idx = Number.isFinite(numeric) && numeric > 0 ? (numeric - 1) % THEME_PALETTE.length : hashToHue(raw || 'U') % THEME_PALETTE.length
const base = THEME_PALETTE[idx]
if (!options.tieBreak) {
return {
'--score-group-accent': base.accent,
'--score-group-soft': base.soft,
'--score-group-border': base.border,
}
}
return {
'--score-group-accent': base.accent,
'--score-group-soft': base.soft.replace('0.15', '0.2'),
'--score-group-border': base.border,
}
}
function hashToHue(value) {
const input = String(value || 'U')
let hash = 0
for (let i = 0; i < input.length; i += 1) {
hash = (hash * 31 + input.charCodeAt(i)) % 360
}
return (hash + 360) % 360
}

14
frontend/vite.config.js Normal file
View 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,
},
},
},
})

137
getdata.json Normal file
View File

@@ -0,0 +1,137 @@
{
"response": {
"protocolVersion": {
"protocol": "HTTP",
"minor": 1,
"major": 1
},
"allHeaders": [],
"statusLine": {
"statusCode": 200,
"protocolVersion": {
"protocol": "HTTP",
"minor": 1,
"major": 1
},
"reasonPhrase": "OK"
},
"locale": "en_GB",
"params": {
"names": []
}
},
"data": [
[
"Input Voltage",
"",
"0",
"0.0",
"V"
],
[
"I/P Fault Voltage",
"",
"0",
"0.0",
"V"
],
[
"Output Voltage",
"",
"0",
"0.0",
"V"
],
[
"Load",
"",
"0",
"0.0",
"%"
],
[
"Input Frequency",
"",
"0",
"0.0",
"Hz"
],
[
"Battery Cell Voltage",
"",
"0",
"0.0",
"V"
],
[
"Temperature",
"",
"0",
"0.0",
"℃"
],
[
"Utility State",
"",
"0",
"0.0",
""
],
[
"Battery Low Voltage",
"",
"0",
"0.0",
""
],
[
"Bypass Mode",
"",
"0",
"0.0",
""
],
[
"Fault State",
"",
"0",
"0.0",
""
],
[
"UPS Type",
"",
"0",
"0.0",
""
],
[
"Testing",
"",
"0",
"0.0",
""
],
[
"Shutdown Active",
"",
"0",
"0.0",
""
],
[
"Buzzer",
"",
"0",
"0.0",
""
],
[
"Communication State",
"2026.04.28 08:05:16",
"0.0",
"Lost",
""
]
]
}

5
phoenix/.formatter.exs Normal file
View File

@@ -0,0 +1,5 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"]
]

31
phoenix/.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Temporary files, for example, from tests.
/tmp/
# Ignore package tarball (built via "mix hex.build").
shooting_event_phx-*.tar
# Database files
*.db
*.db-*

111
phoenix/AGENTS.md Normal file
View File

@@ -0,0 +1,111 @@
This is a web application written using the Phoenix web framework.
## Project guidelines
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
### Phoenix v1.8 guidelines
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
- Anytime you run into errors with no `current_scope` assign:
- You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
- **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
custom classes must fully style the input
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
## Elixir guidelines
- Elixir lists **do not support index based access via the access syntax**
**Never do this (invalid)**:
i = 0
mylist = ["blue", "green"]
mylist[i]
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
i = 0
mylist = ["blue", "green"]
Enum.at(mylist, i)
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
# INVALID: we are rebinding inside the `if` and the result never gets assigned
if connected?(socket) do
socket = assign(socket, :val, val)
end
# VALID: we rebind the result of the `if` to a new variable
socket =
if connected?(socket) do
assign(socket, :val, val)
end
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
- Don't use `String.to_atom/1` on user input (memory leak risk)
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
## Mix guidelines
- Read the docs and options before using tasks (by using `mix help task_name`)
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
## Test guidelines
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
<!-- phoenix:elixir-end -->
<!-- phoenix:phoenix-start -->
## Phoenix guidelines
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
scope "/admin", AppWeb.Admin do
pipe_through :browser
live "/users", UserLive, :index
end
the UserLive route would point to the `AppWeb.Admin.UserLive` module
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
<!-- phoenix:phoenix-end -->
<!-- phoenix:ecto-start -->
## Ecto Guidelines
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
<!-- phoenix:ecto-end -->
<!-- usage-rules-end -->

51
phoenix/README.md Normal file
View File

@@ -0,0 +1,51 @@
# Shooting Event Phoenix Backend
This folder contains a Phoenix + SQLite implementation of the existing Go API contract used by the Vue frontend.
## Run (dev)
```bash
cd phoenix
mix deps.get
mix ecto.create
mix ecto.migrate
PORT=8080 mix phx.server
```
Or from project root:
```bash
make phoenix-dev
```
## Build
```bash
make phoenix-build
```
## API compatibility
The Phoenix app exposes the same `/api/*` endpoints currently used by the Vue app, including:
- Public:
- `GET /api/health`
- `GET /api/state`
- `GET /api/events` (SSE)
- Admin:
- `POST /api/admin/login`
- `POST /api/admin/logout`
- `GET /api/admin/state`
- `PUT /api/admin/settings`
- Player CRUD + auto-group
- Score/proof update/delete + reset
- AI score advice endpoint
## Environment
- `PORT` (default: `8080`)
- `DB_PATH` (default: `../data/shooting_phoenix.db`)
- `ADMIN_USER` (default: `datwyler`)
- `ADMIN_PASS` (default: `datwyler`)
- `GEMINI_API_KEY` (default follows existing project fallback)
- `GEMINI_MODEL` (default: `gemini-3.1-flash-lite-preview`)

35
phoenix/config/config.exs Normal file
View File

@@ -0,0 +1,35 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
config :shooting_event_phx,
ecto_repos: [ShootingEventPhx.Repo],
generators: [timestamp_type: :utc_datetime]
# Configure the endpoint
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [json: ShootingEventPhxWeb.ErrorJSON],
layout: false
],
pubsub_server: ShootingEventPhx.PubSub,
live_view: [signing_salt: "FIh+7Vu7"]
# Configure Elixir's Logger
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

60
phoenix/config/dev.exs Normal file
View File

@@ -0,0 +1,60 @@
import Config
# Configure your database
config :shooting_event_phx, ShootingEventPhx.Repo,
database: Path.expand("../shooting_event_phx_dev.db", __DIR__),
pool_size: 5,
stacktrace: true,
show_sensitive_data_on_connection_error: true
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it
# to bundle .js and .css sources.
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "lw8ztXfcXK/Q4GVw+LtQGEo34zfdV+uCkmdj2BkwfDlxIZzE8jQyMbU8AqKNBoVW",
watchers: []
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
#
# mix phx.gen.cert
#
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
#
# https: [
# port: 4001,
# cipher_suite: :strong,
# keyfile: "priv/cert/selfsigned_key.pem",
# certfile: "priv/cert/selfsigned.pem"
# ],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.
# Enable dev routes for dashboard and mailbox
config :shooting_event_phx, dev_routes: true
# Do not include metadata nor timestamps in development logs
config :logger, :default_formatter, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime

19
phoenix/config/prod.exs Normal file
View File

@@ -0,0 +1,19 @@
import Config
# Force using SSL in production. This also sets the "strict-security-transport" header,
# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
# Note `:force_ssl` is required to be set at compile-time.
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
force_ssl: [
rewrite_on: [:x_forwarded_proto],
exclude: [
# paths: ["/health"],
hosts: ["localhost", "127.0.0.1"]
]
]
# Do not print debug messages in production
config :logger, level: :info
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.

View File

@@ -0,0 +1,92 @@
import Config
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/shooting_event_phx start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint, server: true
end
default_db_path = Path.expand("../../data/shooting_phoenix.db", __DIR__)
database_path = System.get_env("DB_PATH") || System.get_env("DATABASE_PATH") || default_db_path
config :shooting_event_phx, ShootingEventPhx.Repo,
database: database_path,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT", "8080"))]
if config_env() == :prod do
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "example.com"
config :shooting_event_phx, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0}
],
secret_key_base: secret_key_base
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
end

28
phoenix/config/test.exs Normal file
View File

@@ -0,0 +1,28 @@
import Config
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :shooting_event_phx, ShootingEventPhx.Repo,
database: Path.expand("../shooting_event_phx_test.db", __DIR__),
pool_size: 5,
pool: Ecto.Adapters.SQL.Sandbox
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "h/THyq4VyIU51PLFwsrgMS2n9bs3Dj+RkC7my5e/FhUC91q7EUB3WcYfdvEKJgX3",
server: false
# Print only warnings and errors during test
config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
# Sort query params output of verified routes for robust url comparisons
config :phoenix,
sort_verified_routes_query_params: true

View File

@@ -0,0 +1,9 @@
defmodule ShootingEventPhx do
@moduledoc """
ShootingEventPhx keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end

View File

@@ -0,0 +1,199 @@
defmodule ShootingEventPhx.AI.Gemini do
@moduledoc false
def generate_score_advice(stage, player_id, image_data, current_score) do
with api_key when is_binary(api_key) and api_key != "" <- gemini_api_key(),
{:ok, mime_type, raw_base64} <- parse_data_uri(image_data),
{:ok, body} <- build_request_body(stage, current_score, mime_type, raw_base64),
{:ok, response} <- request_gemini(body, api_key),
{:ok, model_response} <- parse_gemini_response(response) do
{:ok,
%{
"stage" => stage,
"playerId" => player_id,
"advisedScore" => clamp(model_response["advisedScore"] || 0, 0, 9999),
"reason" => normalize_reason(model_response["reason"]),
"generatedAt" =>
DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
}}
else
nil -> {:error, :service_unavailable, "gemini api key is not configured"}
{:error, message} when is_binary(message) -> {:error, :bad_gateway, message}
{:error, status, message} -> {:error, status, message}
_ -> {:error, :bad_gateway, "failed to generate score advice"}
end
end
defp gemini_api_key do
System.get_env("GEMINI_API_KEY", "AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc")
|> String.trim()
|> case do
"" -> nil
v -> v
end
end
defp gemini_model do
System.get_env("GEMINI_MODEL", "gemini-3.1-flash-lite-preview")
|> String.trim()
|> case do
"" -> "gemini-3.1-flash-lite-preview"
v -> v
end
end
defp parse_data_uri(data_uri) do
value = data_uri |> to_string() |> String.trim()
if String.starts_with?(value, "data:") do
case String.split(value, ",", parts: 2) do
[header, payload] ->
payload = String.trim(payload)
if payload == "" do
{:error, "invalid proof image data: empty payload"}
else
mime_type =
case String.split(header, ";", parts: 2) do
["data:" <> mime, _] when mime != "" -> mime
_ -> "image/jpeg"
end
{:ok, mime_type, payload}
end
_ ->
{:error, "invalid proof image data: invalid data uri format"}
end
else
{:error, "invalid proof image data: expected data uri"}
end
end
defp build_request_body(stage, current_score, mime_type, raw_base64) do
request = %{
"contents" => [
%{
"role" => "user",
"parts" => [
%{"text" => score_advice_prompt(stage, current_score)},
%{"inline_data" => %{"mime_type" => mime_type, "data" => raw_base64}}
]
}
],
"generationConfig" => %{
"temperature" => 0,
"responseMimeType" => "application/json",
"maxOutputTokens" => 100
}
}
{:ok, Jason.encode!(request)}
end
defp request_gemini(body, api_key) do
endpoint =
"https://generativelanguage.googleapis.com/v1beta/models/#{URI.encode(gemini_model())}:generateContent?key=#{URI.encode_www_form(api_key)}"
case Req.post(endpoint,
headers: [{"content-type", "application/json"}],
body: body,
receive_timeout: 25_000
) do
{:ok, %Req.Response{status: status, body: response_body}} when status < 300 ->
{:ok, response_body}
{:ok, %Req.Response{status: status, body: response_body}} ->
{:error, "gemini api status #{status}: #{inspect(response_body)}"}
{:error, reason} ->
{:error, "call gemini api: #{inspect(reason)}"}
end
end
defp parse_gemini_response(response_body) when is_map(response_body) do
cond do
is_map(response_body["error"]) and is_binary(response_body["error"]["message"]) ->
{:error, "gemini api error: #{response_body["error"]["message"]}"}
true ->
text =
get_in(response_body, [
"candidates",
Access.at(0),
"content",
"parts",
Access.at(0),
"text"
])
|> to_string()
|> String.trim()
json_text = extract_json_object(text)
if json_text == "" do
{:error, "gemini response was not valid json"}
else
case Jason.decode(json_text) do
{:ok, parsed} -> {:ok, parsed}
{:error, reason} -> {:error, "parse gemini advice json: #{inspect(reason)}"}
end
end
end
end
defp parse_gemini_response(_), do: {:error, "decode gemini response: unexpected payload"}
defp extract_json_object(raw) do
text =
raw
|> String.trim()
|> String.trim_leading("```json")
|> String.trim_leading("```")
|> String.trim_trailing("```")
|> String.trim()
start_idx = binary_index(text, "{")
end_idx = binary_rindex(text, "}")
if is_integer(start_idx) and is_integer(end_idx) and end_idx > start_idx do
text
|> String.slice(start_idx..end_idx)
|> String.trim()
else
""
end
end
defp score_advice_prompt(stage, current_score) do
"Target scoring assistant.\nStage: #{stage}. Current score: #{current_score}.\n" <>
"Return STRICT JSON only:\n{\"advisedScore\":<int 0..9999>,\"reason\":\"<max 18 words>\"}\n" <>
"Do not add markdown or extra fields."
end
defp normalize_reason(reason) do
value = reason |> to_string() |> String.trim()
if value == "", do: "AI estimated the score from visible impacts.", else: value
end
defp clamp(value, min, _max) when value < min, do: min
defp clamp(value, _min, max) when value > max, do: max
defp clamp(value, _min, _max), do: value
defp binary_index(text, pattern) do
case :binary.match(text, pattern) do
:nomatch -> nil
{idx, _len} -> idx
end
end
defp binary_rindex(text, pattern) do
text
|> :binary.matches(pattern)
|> List.last()
|> case do
nil -> nil
{idx, _len} -> idx
end
end
end

View File

@@ -0,0 +1,43 @@
defmodule ShootingEventPhx.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
ShootingEventPhxWeb.Telemetry,
ShootingEventPhx.Repo,
{Ecto.Migrator,
repos: Application.fetch_env!(:shooting_event_phx, :ecto_repos), skip: skip_migrations?()},
{DNSCluster,
query: Application.get_env(:shooting_event_phx, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: ShootingEventPhx.PubSub},
ShootingEventPhx.Auth.SessionStore,
# Start a worker by calling: ShootingEventPhx.Worker.start_link(arg)
# {ShootingEventPhx.Worker, arg},
# Start to serve requests, typically the last entry
ShootingEventPhxWeb.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: ShootingEventPhx.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
ShootingEventPhxWeb.Endpoint.config_change(changed, removed)
:ok
end
defp skip_migrations?() do
# By default, sqlite migrations are run when using a release
System.get_env("RELEASE_NAME") == nil
end
end

View File

@@ -0,0 +1,51 @@
defmodule ShootingEventPhx.Auth do
@moduledoc false
alias ShootingEventPhx.Auth.SessionStore
def admin_user, do: System.get_env("ADMIN_USER", "datwyler")
def admin_pass, do: System.get_env("ADMIN_PASS", "datwyler")
def verify_admin(username, password) do
u = username |> to_string() |> String.trim()
p = password |> to_string() |> String.trim()
if u == "" or p == "" do
false
else
secure_compare(u, admin_user()) and secure_compare(p, admin_pass())
end
end
def bearer_token(conn) do
conn
|> Plug.Conn.get_req_header("authorization")
|> List.first()
|> parse_bearer_token()
end
def admin_request?(conn) do
case bearer_token(conn) do
nil -> false
token -> SessionStore.validate_token(token)
end
end
defp parse_bearer_token(nil), do: nil
defp parse_bearer_token(header) do
value = String.trim(header || "")
if String.starts_with?(String.downcase(value), "bearer ") do
token = value |> String.slice(7..-1//1) |> String.trim()
if token == "", do: nil, else: token
else
nil
end
end
defp secure_compare(left, right) do
Plug.Crypto.secure_compare(left, right)
rescue
_ -> false
end
end

View File

@@ -0,0 +1,46 @@
defmodule ShootingEventPhx.Auth.SessionStore do
@moduledoc false
use Agent
@duration_seconds 8 * 60 * 60
def start_link(_opts) do
Agent.start_link(fn -> %{} end, name: __MODULE__)
end
def create_token do
token = :crypto.strong_rand_bytes(32) |> Base.encode16(case: :lower)
expires_at = DateTime.add(DateTime.utc_now(), @duration_seconds, :second)
Agent.update(__MODULE__, &Map.put(&1, token, expires_at))
{:ok, token, expires_at}
end
def validate_token(token) when is_binary(token) do
purge_expired()
Agent.get(__MODULE__, fn tokens ->
case Map.get(tokens, token) do
%DateTime{} = expires_at -> DateTime.compare(DateTime.utc_now(), expires_at) == :lt
_ -> false
end
end)
end
def delete_token(token) when is_binary(token) do
Agent.update(__MODULE__, &Map.delete(&1, token))
end
def purge_expired do
now = DateTime.utc_now()
Agent.update(__MODULE__, fn tokens ->
Enum.reduce(tokens, %{}, fn {token, expires_at}, acc ->
if DateTime.compare(now, expires_at) == :lt do
Map.put(acc, token, expires_at)
else
acc
end
end)
end)
end
end

View File

@@ -0,0 +1,11 @@
defmodule ShootingEventPhx.Domain.AppSetting do
use Ecto.Schema
@primary_key {:key, :string, []}
@timestamps_opts [inserted_at: false, updated_at: :updated_at, type: :utc_datetime]
schema "app_settings" do
field :value, :string, default: ""
timestamps()
end
end

View File

@@ -0,0 +1,272 @@
defmodule ShootingEventPhx.Domain.Derived do
@moduledoc false
alias ShootingEventPhx.Domain.Stages
def compute(players, scores) do
player_by_id = Map.new(players, &{&1.id, &1})
pre_rows =
players
|> Enum.map(fn p ->
%{
"playerId" => p.id,
"nameAr" => p.name_ar,
"nameEn" => p.name_en,
"groupCode" => p.group_code,
"imageData" => p.image_data,
"score" => score_total(scores, p.id, Stages.preliminary_round_stages()),
"tieBreak" => get_score(scores, "prelim_tiebreak", p.id),
"rank" => 0,
"seed" => 0,
"finalGroup" => 0
}
end)
|> Enum.sort_by(fn row -> {-row["score"], row["playerId"]} end)
|> assign_dense_rank_by_score()
{pre_tie, finalists} = compute_finalists(pre_rows)
finalists =
finalists
|> Enum.with_index(1)
|> Enum.map(fn {row, idx} ->
row
|> Map.put("seed", idx)
|> Map.put("finalGroup", if(idx <= 6, do: 1, else: 2))
end)
final_group1 = Enum.filter(finalists, &(&1["finalGroup"] == 1))
final_group2 = Enum.filter(finalists, &(&1["finalGroup"] == 2))
final_rows =
finalists
|> Enum.map(fn finalist ->
p = Map.fetch!(player_by_id, finalist["playerId"])
%{
"playerId" => finalist["playerId"],
"nameAr" => p.name_ar,
"nameEn" => p.name_en,
"groupCode" => p.group_code,
"imageData" => p.image_data,
"score" => score_total(scores, finalist["playerId"], Stages.final_round_stages()),
"tieBreak" => get_score(scores, "final_tiebreak", finalist["playerId"]),
"rank" => 0,
"seed" => finalist["seed"],
"finalGroup" => finalist["finalGroup"]
}
end)
|> compute_final_ranking()
podium = final_rows |> Enum.take(3)
final_tie = final_tie_info(final_rows)
%{
"preliminaryRanking" => %{
"rows" => pre_rows,
"tieBreak" => pre_tie,
"unresolved" => pre_tie["required"] and not pre_tie["resolved"]
},
"finalists" => finalists,
"finalGroups" => %{"group1" => final_group1, "group2" => final_group2},
"finalRanking" => %{
"rows" => final_rows,
"tieBreak" => final_tie,
"unresolved" => final_tie["required"] and not final_tie["resolved"]
},
"podium" => podium
}
end
defp compute_finalists(pre_rows) do
if length(pre_rows) <= 12 do
finalists =
pre_rows
|> Enum.with_index(1)
|> Enum.map(fn {row, seed} -> Map.put(row, "seed", seed) end)
{empty_tie(), finalists}
else
cutoff = Enum.at(pre_rows, 11)["score"]
above = Enum.filter(pre_rows, &(&1["score"] > cutoff))
at_cutoff = Enum.filter(pre_rows, &(&1["score"] == cutoff))
slots = max(12 - length(above), 0)
if length(at_cutoff) <= slots do
{empty_tie(), above ++ at_cutoff}
else
tie = %{
"required" => true,
"resolved" => true,
"slots" => slots,
"playerIds" => at_cutoff |> Enum.map(& &1["playerId"]) |> Enum.sort()
}
sorted_at_cutoff =
Enum.sort_by(at_cutoff, fn row -> {-row["tieBreak"], row["playerId"]} end)
tie =
if slots > 0 do
boundary = Enum.at(sorted_at_cutoff, slots - 1)["tieBreak"]
greater = Enum.count(sorted_at_cutoff, &(&1["tieBreak"] > boundary))
equal = Enum.count(sorted_at_cutoff, &(&1["tieBreak"] == boundary))
resolved = not (greater < slots and greater + equal > slots)
Map.put(tie, "resolved", resolved)
else
tie
end
finalists = above ++ Enum.take(sorted_at_cutoff, min(slots, length(sorted_at_cutoff)))
finalists =
finalists
|> Enum.sort_by(fn row ->
if tie["required"] do
{-row["score"], -row["tieBreak"], row["playerId"]}
else
{-row["score"], row["playerId"]}
end
end)
|> assign_dense_rank(fn a, b ->
if tie["required"],
do: a["score"] == b["score"] and a["tieBreak"] == b["tieBreak"],
else: a["score"] == b["score"]
end)
{tie, finalists}
end
end
end
defp compute_final_ranking(rows) do
sorted = Enum.sort_by(rows, fn row -> {-row["score"], row["seed"]} end)
tied_top = tied_top_for_podium(sorted)
rows =
if MapSet.size(tied_top) > 0 do
Enum.sort_by(sorted, fn row ->
tied = MapSet.member?(tied_top, row["playerId"])
tie_break_key = if tied, do: -row["tieBreak"], else: 0
{-row["score"], tie_break_key, row["seed"]}
end)
else
sorted
end
if MapSet.size(tied_top) > 0 do
assign_dense_rank(rows, fn a, b ->
if a["score"] != b["score"] do
false
else
tied_a = MapSet.member?(tied_top, a["playerId"])
tied_b = MapSet.member?(tied_top, b["playerId"])
if tied_a and tied_b, do: a["tieBreak"] == b["tieBreak"], else: true
end
end)
else
assign_dense_rank_by_score(rows)
end
end
defp final_tie_info(final_rows) do
tied_top = tied_top_for_podium(final_rows)
if MapSet.size(tied_top) == 0 do
empty_tie()
else
player_ids = tied_top |> MapSet.to_list() |> Enum.sort()
resolved =
if length(final_rows) >= 3 do
third = Enum.at(final_rows, 2)
{greater, equal} =
Enum.reduce(final_rows, {0, 0}, fn row, {g, e} ->
cond do
row["score"] > third["score"] ->
{g + 1, e}
row["score"] < third["score"] ->
{g, e}
true ->
row_tied = MapSet.member?(tied_top, row["playerId"])
third_tied = MapSet.member?(tied_top, third["playerId"])
cond do
row_tied and third_tied and row["tieBreak"] > third["tieBreak"] -> {g + 1, e}
row_tied and third_tied and row["tieBreak"] == third["tieBreak"] -> {g, e + 1}
not row_tied or not third_tied -> {g, e + 1}
true -> {g, e}
end
end
end)
not (greater < 3 and greater + equal > 3)
else
true
end
%{"required" => true, "resolved" => resolved, "slots" => 0, "playerIds" => player_ids}
end
end
defp tied_top_for_podium(rows) do
rows
|> Enum.chunk_by(& &1["score"])
|> Enum.reduce({MapSet.new(), 1}, fn chunk, {set, start_pos} ->
size = length(chunk)
end_pos = start_pos + size - 1
score = List.first(chunk)["score"]
include? =
size > 1 and score > 0 and
(start_pos <= 3 or end_pos <= 3 or (start_pos < 3 and end_pos > 3))
next_set =
if include? do
Enum.reduce(chunk, set, fn row, acc -> MapSet.put(acc, row["playerId"]) end)
else
set
end
{next_set, end_pos + 1}
end)
|> elem(0)
end
defp empty_tie, do: %{"required" => false, "resolved" => true, "slots" => 0, "playerIds" => []}
defp score_total(scores, player_id, stages) do
Enum.reduce(stages, 0, fn stage, acc -> acc + get_score(scores, stage, player_id) end)
end
defp get_score(scores, stage, player_id) do
stage_map = Map.get(scores, stage, %{})
Map.get(stage_map, player_id, 0)
end
defp assign_dense_rank_by_score(rows) do
assign_dense_rank(rows, fn a, b -> a["score"] == b["score"] end)
end
defp assign_dense_rank([], _equal_fun), do: []
defp assign_dense_rank(rows, equal_fun) do
{ranked_rev, _prev, _rank, _idx} =
Enum.reduce(rows, {[], nil, 1, 0}, fn row, {acc, prev, rank, idx} ->
idx1 = idx + 1
next_rank =
if is_map(prev) and equal_fun.(row, prev) do
rank
else
idx1
end
{[%{row | "rank" => next_rank} | acc], row, next_rank, idx1}
end)
Enum.reverse(ranked_rev)
end
end

View File

@@ -0,0 +1,353 @@
defmodule ShootingEventPhx.Domain.EventData do
@moduledoc false
import Ecto.Query
alias ShootingEventPhx.Repo
alias ShootingEventPhx.Domain.{Player, Score, ScoreAttachment, Stages}
def list_players do
Repo.all(from p in Player, order_by: [asc: p.id])
end
def ensure_score_rows(players) do
Repo.transaction(fn ->
for player <- players, stage <- Stages.score_stages() do
Repo.query!(
"""
INSERT OR IGNORE INTO scores(stage, player_id, score)
VALUES(?1, ?2, 0)
""",
[stage, player.id]
)
end
end)
end
def read_scores(players) do
base =
for stage <- Stages.score_stages(), into: %{} do
{stage, Map.new(players, &{&1.id, 0})}
end
rows =
Repo.all(
from s in Score,
select: {s.stage, s.player_id, s.score}
)
Enum.reduce(rows, base, fn {stage, player_id, score}, acc ->
if Map.has_key?(acc, stage) do
Map.update!(acc, stage, &Map.put(&1, player_id, score))
else
acc
end
end)
end
def read_score_proofs(players) do
base =
for stage <- Stages.score_stages(), into: %{} do
{stage, Map.new(players, &{&1.id, ""})}
end
rows =
Repo.all(
from sa in ScoreAttachment,
select: {sa.stage, sa.player_id, sa.image_data}
)
Enum.reduce(rows, base, fn {stage, player_id, image_data}, acc ->
if Map.has_key?(acc, stage) do
Map.update!(acc, stage, &Map.put(&1, player_id, image_data || ""))
else
acc
end
end)
end
def score_map_to_json(scores) do
for {stage, stage_map} <- scores, into: %{} do
{stage,
for({player_id, value} <- stage_map, into: %{}, do: {Integer.to_string(player_id), value})}
end
end
def proof_map_to_json(proofs) do
for {stage, stage_map} <- proofs, into: %{} do
compact =
for {player_id, image_data} <- stage_map,
String.trim(to_string(image_data)) != "",
into: %{} do
{Integer.to_string(player_id), image_data}
end
{stage, compact}
end
end
def create_player(attrs) do
name_ar = attrs["nameAr"] |> to_string() |> String.trim()
name_en = attrs["nameEn"] |> to_string() |> String.trim()
group_code = attrs["groupCode"] |> normalize_group()
cond do
name_ar == "" or name_en == "" ->
{:error, :bad_request, "both Arabic and English names are required"}
true ->
Repo.transaction(fn ->
{:ok, player} =
%Player{name_ar: name_ar, name_en: name_en, group_code: group_code, image_data: ""}
|> Repo.insert()
for stage <- Stages.score_stages() do
Repo.query!(
"""
INSERT OR IGNORE INTO scores(stage, player_id, score) VALUES(?1, ?2, 0)
""",
[stage, player.id]
)
end
end)
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, :internal, inspect(reason)}
end
end
end
def update_player(id, attrs) do
with %Player{} = player <- Repo.get(Player, id) do
updates = player_updates(attrs)
cond do
Map.has_key?(updates, :_error) ->
{:error, :bad_request, Map.get(updates, :_error)}
map_size(updates) == 0 ->
{:error, :bad_request, "no fields to update"}
true ->
case Repo.update(Ecto.Changeset.change(player, updates)) do
{:ok, _} -> :ok
{:error, changeset} -> {:error, :bad_request, format_changeset_errors(changeset)}
end
end
else
nil -> {:error, :not_found, "player not found"}
end
end
def delete_player(id) do
case Repo.get(Player, id) do
nil ->
{:error, :not_found, "player not found"}
player ->
case Repo.delete(player) do
{:ok, _} -> :ok
{:error, reason} -> {:error, :internal, inspect(reason)}
end
end
end
def auto_group_players(groups) do
normalized_groups =
groups
|> List.wrap()
|> Enum.map(&normalize_group/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
if normalized_groups == [] do
{:error, :bad_request, "at least one group is required"}
else
player_ids = Repo.all(from p in Player, select: p.id, order_by: [asc: p.id])
shuffled = Enum.shuffle(player_ids)
case Repo.transaction(fn ->
Enum.with_index(shuffled)
|> Enum.each(fn {player_id, idx} ->
group_code = Enum.at(normalized_groups, rem(idx, length(normalized_groups)))
Repo.query!(
"UPDATE players SET group_code = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
[group_code, player_id]
)
end)
end) do
{:ok, _} -> :ok
{:error, reason} -> {:error, :internal, inspect(reason)}
end
end
end
def update_score(stage, player_id, score) when is_integer(score) do
if score < 0 or score > 9999 do
{:error, :bad_request, "score must be between 0 and 9999"}
else
Repo.query!(
"""
INSERT INTO scores(stage, player_id, score)
VALUES(?1, ?2, ?3)
ON CONFLICT(stage, player_id) DO UPDATE SET score = excluded.score, updated_at = CURRENT_TIMESTAMP
""",
[stage, player_id, score]
)
:ok
end
end
def update_score_proof(stage, player_id, image_data) do
payload = to_string(image_data || "") |> String.trim()
cond do
payload == "" ->
{:error, :bad_request, "imageData is required"}
byte_size(payload) > 5_000_000 ->
{:error, :bad_request, "image payload too large"}
true ->
Repo.query!(
"""
INSERT INTO score_attachments(stage, player_id, image_data)
VALUES(?1, ?2, ?3)
ON CONFLICT(stage, player_id) DO UPDATE SET image_data = excluded.image_data, updated_at = CURRENT_TIMESTAMP
""",
[stage, player_id, payload]
)
:ok
end
end
def delete_score_proof(stage, player_id) do
Repo.query!("DELETE FROM score_attachments WHERE stage = ?1 AND player_id = ?2", [
stage,
player_id
])
:ok
end
def reset_stage_scores(stages, reset_proofs?) do
case Repo.transaction(fn ->
Enum.each(stages, fn stage ->
Repo.query!(
"UPDATE scores SET score = 0, updated_at = CURRENT_TIMESTAMP WHERE stage = ?1",
[stage]
)
if reset_proofs? do
Repo.query!("DELETE FROM score_attachments WHERE stage = ?1", [stage])
end
end)
end) do
{:ok, _} -> :ok
{:error, reason} -> {:error, :internal, inspect(reason)}
end
end
def load_score_proof_image(stage, player_id) do
case Repo.one(
from sa in ScoreAttachment,
where: sa.stage == ^stage and sa.player_id == ^player_id,
select: sa.image_data
) do
nil -> {:error, :not_found}
image -> {:ok, image}
end
end
def load_current_score(stage, player_id) do
score =
Repo.one(
from s in Score, where: s.stage == ^stage and s.player_id == ^player_id, select: s.score
)
{:ok, score || 0}
end
defp player_updates(attrs) do
updates = %{}
updates =
case Map.get(attrs, "nameAr") do
nil ->
updates
name ->
trimmed = to_string(name) |> String.trim()
if trimmed == "",
do: Map.put(updates, :_error, "arabic name cannot be empty"),
else: Map.put(updates, :name_ar, trimmed)
end
updates =
case Map.get(attrs, "nameEn") do
nil ->
updates
name ->
trimmed = to_string(name) |> String.trim()
if trimmed == "",
do: Map.put(updates, :_error, "english name cannot be empty"),
else: Map.put(updates, :name_en, trimmed)
end
updates =
if Map.has_key?(attrs, "groupCode"),
do: Map.put(updates, :group_code, normalize_group(Map.get(attrs, "groupCode"))),
else: updates
updates =
if Map.has_key?(attrs, "imageData") do
image = attrs |> Map.get("imageData") |> to_string() |> String.trim()
Map.put(updates, :image_data, image)
else
updates
end
updates =
if truthy?(Map.get(attrs, "clearImage")) do
Map.put(updates, :image_data, "")
else
updates
end
case Map.pop(updates, :_error) do
{nil, sanitized} ->
if byte_size(Map.get(sanitized, :image_data, "")) > 2_500_000 do
%{_error: "image payload too large"}
else
sanitized
end
{message, _} ->
%{_error: message}
end
end
defp normalize_group(group), do: group |> to_string() |> String.trim()
defp truthy?(value) do
case value do
true -> true
1 -> true
"1" -> true
"true" -> true
"yes" -> true
"on" -> true
_ -> false
end
end
defp format_changeset_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
|> Enum.map(fn {field, msgs} -> "#{field} #{Enum.join(msgs, ",")}" end)
|> Enum.join("; ")
end
end

View File

@@ -0,0 +1,14 @@
defmodule ShootingEventPhx.Domain.Player do
use Ecto.Schema
@primary_key {:id, :id, autogenerate: true}
@timestamps_opts [inserted_at: :created_at, updated_at: :updated_at, type: :utc_datetime]
schema "players" do
field :name_ar, :string
field :name_en, :string
field :group_code, :string, default: ""
field :image_data, :string, default: ""
timestamps()
end
end

View File

@@ -0,0 +1,13 @@
defmodule ShootingEventPhx.Domain.Score do
use Ecto.Schema
@primary_key false
@timestamps_opts [inserted_at: false, updated_at: :updated_at, type: :utc_datetime]
schema "scores" do
field :stage, :string, primary_key: true
field :player_id, :id, primary_key: true
field :score, :integer, default: 0
timestamps()
end
end

View File

@@ -0,0 +1,13 @@
defmodule ShootingEventPhx.Domain.ScoreAttachment do
use Ecto.Schema
@primary_key false
@timestamps_opts [inserted_at: false, updated_at: :updated_at, type: :utc_datetime]
schema "score_attachments" do
field :stage, :string, primary_key: true
field :player_id, :id, primary_key: true
field :image_data, :string, default: ""
timestamps()
end
end

View File

@@ -0,0 +1,297 @@
defmodule ShootingEventPhx.Domain.Settings do
@moduledoc false
import Ecto.Query
alias ShootingEventPhx.Repo
alias ShootingEventPhx.Domain.AppSetting
@setting_view_proof_in_view "view_proof_in_view"
@setting_live_active_view "live_active_view"
@setting_live_show_group_live "live_show_group_live"
@setting_live_group_display_mode "live_group_display_mode"
@setting_live_group_fixed_code "live_group_fixed_code"
@setting_live_show_prelim_tie "live_show_prelim_tie"
@setting_live_show_prelim_overall "live_show_prelim_overall"
@setting_live_show_final_groups "live_show_final_groups"
@setting_live_final_display_mode "live_final_display_mode"
@setting_live_final_fixed_group "live_final_fixed_group"
@setting_live_show_final_tie "live_show_final_tie"
@setting_live_show_podium "live_show_podium"
@setting_live_rotation_interval "live_rotation_interval_sec"
@setting_live_rotation_players "live_rotation_player_count"
@allowed_live_views MapSet.new([
"group_live",
"prelim_tie",
"prelim_overall",
"final_groups",
"final_tie",
"podium"
])
def default_live_mode do
%{
"activeView" => "group_live",
"showGroupLive" => true,
"groupDisplayMode" => "rotate",
"groupFixedCode" => "",
"showPrelimTie" => true,
"showPrelimOverall" => true,
"showFinalGroups" => true,
"finalDisplayMode" => "rotate",
"finalFixedGroup" => 1,
"showFinalTie" => true,
"showPodium" => true,
"rotationIntervalSec" => 5,
"rotationPlayerCount" => 12
}
end
def read_settings do
all =
Repo.all(from s in AppSetting, select: {s.key, s.value})
|> Map.new()
live = default_live_mode()
live =
live
|> Map.put("showGroupLive", setting_bool(Map.get(all, @setting_live_show_group_live, "1")))
|> Map.put(
"activeView",
normalize_live_active_view(
Map.get(all, @setting_live_active_view, live["activeView"]),
live["activeView"]
)
)
|> Map.put(
"groupDisplayMode",
normalize_display_mode(
Map.get(all, @setting_live_group_display_mode, live["groupDisplayMode"]),
live["groupDisplayMode"]
)
)
|> Map.put(
"groupFixedCode",
String.upcase(String.trim(Map.get(all, @setting_live_group_fixed_code, "")))
)
|> Map.put("showPrelimTie", setting_bool(Map.get(all, @setting_live_show_prelim_tie, "1")))
|> Map.put(
"showPrelimOverall",
setting_bool(Map.get(all, @setting_live_show_prelim_overall, "1"))
)
|> Map.put(
"showFinalGroups",
setting_bool(Map.get(all, @setting_live_show_final_groups, "1"))
)
|> Map.put(
"finalDisplayMode",
normalize_display_mode(
Map.get(all, @setting_live_final_display_mode, live["finalDisplayMode"]),
live["finalDisplayMode"]
)
)
|> Map.put(
"finalFixedGroup",
normalize_final_fixed_group(Map.get(all, @setting_live_final_fixed_group, "1"), 1)
)
|> Map.put("showFinalTie", setting_bool(Map.get(all, @setting_live_show_final_tie, "1")))
|> Map.put("showPodium", setting_bool(Map.get(all, @setting_live_show_podium, "1")))
|> Map.put(
"rotationIntervalSec",
normalize_rotation_interval(Map.get(all, @setting_live_rotation_interval, "5"), 5)
)
|> Map.put(
"rotationPlayerCount",
normalize_rotation_player_count(Map.get(all, @setting_live_rotation_players, "12"), 12)
)
%{
"viewProofInView" => setting_bool(Map.get(all, @setting_view_proof_in_view, "0")),
"liveMode" => live
}
end
def update_settings(%{} = req) do
has_update =
Map.has_key?(req, "viewProofInView") or
Map.has_key?(req, :viewProofInView) or
Map.has_key?(req, "liveMode") or
Map.has_key?(req, :liveMode)
if not has_update do
{:error, "at least one settings field is required"}
else
Repo.transaction(fn ->
req
|> get_value("viewProofInView")
|> maybe_upsert(@setting_view_proof_in_view)
live_patch = get_value(req, "liveMode")
if is_map(live_patch), do: persist_live_patch(live_patch)
end)
|> case do
{:ok, _} -> :ok
{:error, %Ecto.ConstraintError{} = e} -> {:error, Exception.message(e)}
{:error, reason} when is_binary(reason) -> {:error, reason}
{:error, reason} -> {:error, inspect(reason)}
end
end
end
defp persist_live_patch(patch) do
patch
|> get_value("activeView")
|> maybe_upsert(@setting_live_active_view, fn v ->
normalize_live_active_view(v, "group_live")
end)
patch
|> get_value("showGroupLive")
|> maybe_upsert(@setting_live_show_group_live)
patch
|> get_value("groupDisplayMode")
|> maybe_upsert(@setting_live_group_display_mode, fn v ->
normalize_display_mode(v, "rotate")
end)
patch
|> get_value("groupFixedCode")
|> maybe_upsert(@setting_live_group_fixed_code, fn v ->
v |> to_string() |> String.trim() |> String.upcase()
end)
patch
|> get_value("showPrelimTie")
|> maybe_upsert(@setting_live_show_prelim_tie)
patch
|> get_value("showPrelimOverall")
|> maybe_upsert(@setting_live_show_prelim_overall)
patch
|> get_value("showFinalGroups")
|> maybe_upsert(@setting_live_show_final_groups)
patch
|> get_value("finalDisplayMode")
|> maybe_upsert(@setting_live_final_display_mode, fn v ->
normalize_display_mode(v, "rotate")
end)
patch
|> get_value("finalFixedGroup")
|> maybe_upsert(@setting_live_final_fixed_group, fn v -> normalize_final_fixed_group(v, 1) end)
patch
|> get_value("showFinalTie")
|> maybe_upsert(@setting_live_show_final_tie)
patch
|> get_value("showPodium")
|> maybe_upsert(@setting_live_show_podium)
patch
|> get_value("rotationIntervalSec")
|> maybe_upsert(@setting_live_rotation_interval, fn v -> normalize_rotation_interval(v, 5) end)
patch
|> get_value("rotationPlayerCount")
|> maybe_upsert(@setting_live_rotation_players, fn v ->
normalize_rotation_player_count(v, 12)
end)
end
defp maybe_upsert(nil, _key), do: :ok
defp maybe_upsert(value, key) do
normalized = setting_string(value)
Repo.insert!(
%AppSetting{key: key, value: normalized},
on_conflict: [set: [value: normalized, updated_at: DateTime.utc_now()]],
conflict_target: :key
)
end
defp maybe_upsert(nil, _key, _fun), do: :ok
defp maybe_upsert(value, key, normalize_fun) do
normalized = value |> normalize_fun.() |> setting_string()
maybe_upsert(normalized, key)
end
defp get_value(map, key) when is_map(map) do
cond do
Map.has_key?(map, key) -> Map.get(map, key)
Map.has_key?(map, String.to_atom(key)) -> Map.get(map, String.to_atom(key))
true -> nil
end
end
defp setting_bool(value) do
case value |> to_string() |> String.trim() |> String.downcase() do
"1" -> true
"true" -> true
"yes" -> true
"on" -> true
_ -> false
end
end
defp setting_string(v) when is_boolean(v), do: if(v, do: "1", else: "0")
defp setting_string(v) when is_integer(v), do: Integer.to_string(v)
defp setting_string(v), do: v |> to_string()
defp normalize_display_mode(value, fallback) do
case value |> to_string() |> String.trim() |> String.downcase() do
"fixed" -> "fixed"
"rotate" -> "rotate"
_ -> if(String.downcase(to_string(fallback)) == "fixed", do: "fixed", else: "rotate")
end
end
defp normalize_final_fixed_group(value, fallback) do
parsed =
case Integer.parse(to_string(value)) do
{v, _} -> v
_ -> fallback
end
if parsed == 2, do: 2, else: 1
end
defp normalize_rotation_interval(value, fallback) do
parsed =
case Integer.parse(to_string(value)) do
{v, _} -> v
_ -> fallback
end
parsed |> max(3) |> min(30)
end
defp normalize_rotation_player_count(value, fallback) do
parsed =
case Integer.parse(to_string(value)) do
{v, _} -> v
_ -> fallback
end
parsed |> max(3) |> min(40)
end
defp normalize_live_active_view(value, fallback) do
normalized =
value
|> to_string()
|> String.trim()
|> String.downcase()
cond do
MapSet.member?(@allowed_live_views, normalized) -> normalized
MapSet.member?(@allowed_live_views, to_string(fallback)) -> fallback
true -> "group_live"
end
end
end

View File

@@ -0,0 +1,42 @@
defmodule ShootingEventPhx.Domain.Stages do
@moduledoc false
@preliminary_round_stages ["prelim_r1", "prelim_r2", "prelim_r3"]
@final_round_stages ["final_r1", "final_r2"]
@tie_break_stages ["prelim_tiebreak", "final_tiebreak"]
@score_stages @preliminary_round_stages ++ @final_round_stages ++ @tie_break_stages
def preliminary_round_stages, do: @preliminary_round_stages
def final_round_stages, do: @final_round_stages
def tie_break_stages, do: @tie_break_stages
def score_stages, do: @score_stages
def valid_stage?(stage), do: normalize_stage(stage) in @score_stages
def normalize_stage(stage) do
stage
|> to_string()
|> String.trim()
|> String.downcase()
end
def validate_stage(stage) do
normalized = normalize_stage(stage)
if normalized in @score_stages do
{:ok, normalized}
else
{:error, "invalid stage"}
end
end
def resolve_reset_stages(stage) do
case normalize_stage(stage) do
"preliminary" -> {:ok, @preliminary_round_stages}
"final" -> {:ok, @final_round_stages ++ ["final_tiebreak"]}
"prelim_tiebreak" -> {:ok, ["prelim_tiebreak"]}
"final_tiebreak" -> {:ok, ["final_tiebreak"]}
_ -> {:error, "invalid stage"}
end
end
end

View File

@@ -0,0 +1,43 @@
defmodule ShootingEventPhx.Domain.StateService do
@moduledoc false
alias ShootingEventPhx.Domain.{Derived, EventData, Settings}
def read_state(include_all_proofs \\ false) do
players = EventData.list_players()
_ = EventData.ensure_score_rows(players)
settings = Settings.read_settings()
scores = EventData.read_scores(players)
include_score_proofs = include_all_proofs or settings["viewProofInView"]
score_proofs = if include_score_proofs, do: EventData.read_score_proofs(players), else: %{}
derived = Derived.compute(players, scores)
response = %{
"competition" => %{
"titleAr" => "بطولة دويتوايلر للرماية",
"titleEn" => "Datwyler Shooting Event"
},
"players" => Enum.map(players, &player_to_json/1),
"scores" => EventData.score_map_to_json(scores),
"settings" => settings,
"derived" => derived,
"serverTime" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
}
if include_score_proofs do
Map.put(response, "scoreProofs", EventData.proof_map_to_json(score_proofs))
else
response
end
end
defp player_to_json(player) do
%{
"id" => player.id,
"nameAr" => player.name_ar,
"nameEn" => player.name_en,
"groupCode" => player.group_code,
"imageData" => player.image_data
}
end
end

View File

@@ -0,0 +1,15 @@
defmodule ShootingEventPhx.Events do
@moduledoc false
@topic "state_updates"
def topic, do: @topic
def subscribe do
Phoenix.PubSub.subscribe(ShootingEventPhx.PubSub, @topic)
end
def broadcast do
payload = {:state, DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()}
Phoenix.PubSub.broadcast(ShootingEventPhx.PubSub, @topic, payload)
end
end

View File

@@ -0,0 +1,5 @@
defmodule ShootingEventPhx.Repo do
use Ecto.Repo,
otp_app: :shooting_event_phx,
adapter: Ecto.Adapters.SQLite3
end

View File

@@ -0,0 +1,63 @@
defmodule ShootingEventPhxWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use ShootingEventPhxWeb, :controller
use ShootingEventPhxWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: ShootingEventPhxWeb.Endpoint,
router: ShootingEventPhxWeb.Router,
statics: ShootingEventPhxWeb.static_paths()
end
end
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View File

@@ -0,0 +1,221 @@
defmodule ShootingEventPhxWeb.ApiController do
use ShootingEventPhxWeb, :controller
alias ShootingEventPhx.{Auth, Events}
alias ShootingEventPhx.Auth.SessionStore
alias ShootingEventPhx.Domain.{EventData, Settings, Stages, StateService}
alias ShootingEventPhx.AI.Gemini
def health(conn, _params) do
json(conn, %{"status" => "ok"})
end
def state(conn, _params) do
state = StateService.read_state(Auth.admin_request?(conn))
json(conn, state)
end
def admin_state(conn, _params) do
json(conn, StateService.read_state(true))
end
def admin_login(conn, params) do
username = Map.get(params, "username", "")
password = Map.get(params, "password", "")
if Auth.verify_admin(username, password) do
{:ok, token, expires_at} = SessionStore.create_token()
json(conn, %{
"token" => token,
"expiresAt" => DateTime.to_iso8601(expires_at),
"username" => Auth.admin_user()
})
else
error(conn, :unauthorized, "invalid credentials")
end
end
def admin_logout(conn, _params) do
case Auth.bearer_token(conn) do
nil -> :ok
token -> SessionStore.delete_token(token)
end
json(conn, %{"ok" => true})
end
def update_settings(conn, params) do
case Settings.update_settings(params) do
:ok ->
Events.broadcast()
json(conn, StateService.read_state(true))
{:error, message} ->
error(conn, :bad_request, message)
end
end
def create_player(conn, params) do
case EventData.create_player(params) do
:ok ->
Events.broadcast()
conn |> put_status(:created) |> json(StateService.read_state(true))
{:error, :bad_request, message} ->
error(conn, :bad_request, message)
{:error, _, message} ->
error(conn, :internal_server_error, message)
end
end
def update_player(conn, %{"id" => id} = params) do
with {player_id, ""} <- Integer.parse(to_string(id)),
result <- EventData.update_player(player_id, params),
:ok <- result do
Events.broadcast()
json(conn, StateService.read_state(true))
else
:error -> error(conn, :bad_request, "invalid player id")
{:error, :bad_request, message} -> error(conn, :bad_request, message)
{:error, :not_found, message} -> error(conn, :not_found, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def delete_player(conn, %{"id" => id}) do
with {player_id, ""} <- Integer.parse(to_string(id)),
:ok <- EventData.delete_player(player_id) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
:error -> error(conn, :bad_request, "invalid player id")
{:error, :not_found, message} -> error(conn, :not_found, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def auto_group_players(conn, params) do
groups = Map.get(params, "groups", [])
case EventData.auto_group_players(groups) do
:ok ->
Events.broadcast()
json(conn, StateService.read_state(true))
{:error, :bad_request, message} ->
error(conn, :bad_request, message)
{:error, _, message} ->
error(conn, :internal_server_error, message)
end
end
def update_score(conn, %{"stage" => stage, "id" => id} = params) do
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
{player_id, ""} <- Integer.parse(to_string(id)),
score <- parse_score(Map.get(params, "score")),
:ok <- EventData.update_score(normalized_stage, player_id, score) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
{:error, message} -> error(conn, :bad_request, message)
:error -> error(conn, :bad_request, "invalid player id")
{:invalid_score, message} -> error(conn, :bad_request, message)
{:error, :bad_request, message} -> error(conn, :bad_request, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def reset_stage(conn, %{"stage" => stage} = params) do
with {:ok, target_stages} <- Stages.resolve_reset_stages(stage),
reset_proofs <- truthy?(Map.get(params, "resetProofs")),
:ok <- EventData.reset_stage_scores(target_stages, reset_proofs) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
{:error, message} -> error(conn, :bad_request, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def update_score_proof(conn, %{"stage" => stage, "id" => id} = params) do
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
{player_id, ""} <- Integer.parse(to_string(id)),
image_data when is_binary(image_data) <- Map.get(params, "imageData"),
:ok <- EventData.update_score_proof(normalized_stage, player_id, image_data) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
{:error, message} -> error(conn, :bad_request, message)
:error -> error(conn, :bad_request, "invalid player id")
nil -> error(conn, :bad_request, "imageData is required")
{:error, :bad_request, message} -> error(conn, :bad_request, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def delete_score_proof(conn, %{"stage" => stage, "id" => id}) do
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
{player_id, ""} <- Integer.parse(to_string(id)),
:ok <- EventData.delete_score_proof(normalized_stage, player_id) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
{:error, message} -> error(conn, :bad_request, message)
:error -> error(conn, :bad_request, "invalid player id")
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def score_advice(conn, %{"stage" => stage, "id" => id}) do
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
{player_id, ""} <- Integer.parse(to_string(id)),
{:ok, image_data} <- EventData.load_score_proof_image(normalized_stage, player_id),
{:ok, current_score} <- EventData.load_current_score(normalized_stage, player_id),
{:ok, advice} <-
Gemini.generate_score_advice(normalized_stage, player_id, image_data, current_score) do
json(conn, advice)
else
{:error, :not_found} -> error(conn, :bad_request, "no proof image found for this score")
{:error, :service_unavailable, message} -> error(conn, :service_unavailable, message)
{:error, :bad_gateway, message} -> error(conn, :bad_gateway, message)
{:error, message} when is_binary(message) -> error(conn, :bad_request, message)
:error -> error(conn, :bad_request, "invalid player id")
end
end
defp parse_score(value) do
cond do
is_integer(value) ->
value
is_binary(value) ->
case Integer.parse(String.trim(value)) do
{v, ""} -> v
_ -> {:invalid_score, "invalid request body"}
end
true ->
{:invalid_score, "invalid request body"}
end
end
defp truthy?(value) do
case value do
true -> true
1 -> true
"1" -> true
"true" -> true
"yes" -> true
"on" -> true
_ -> false
end
end
defp error(conn, status, message) do
conn
|> put_status(status)
|> json(%{"message" => message})
end
end

View File

@@ -0,0 +1,21 @@
defmodule ShootingEventPhxWeb.ErrorJSON do
@moduledoc """
This module is invoked by your endpoint in case of errors on JSON requests.
See config/config.exs.
"""
# If you want to customize a particular status code,
# you may add your own clauses, such as:
#
# def render("500.json", _assigns) do
# %{errors: %{detail: "Internal Server Error"}}
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.json" becomes
# "Not Found".
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end

View File

@@ -0,0 +1,40 @@
defmodule ShootingEventPhxWeb.EventsController do
use ShootingEventPhxWeb, :controller
alias ShootingEventPhx.Events
def stream(conn, _params) do
conn =
conn
|> put_resp_header("content-type", "text/event-stream")
|> put_resp_header("cache-control", "no-cache, no-transform")
|> put_resp_header("connection", "keep-alive")
|> put_resp_header("x-accel-buffering", "no")
|> send_chunked(200)
now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
{:ok, conn} = chunk(conn, "event: ready\ndata: {\"ts\":\"#{now}\"}\n\n")
:ok = Events.subscribe()
loop_stream(conn)
end
defp loop_stream(conn) do
receive do
{:state, ts} ->
case chunk(conn, "event: state\ndata: {\"ts\":\"#{ts}\"}\n\n") do
{:ok, conn} -> loop_stream(conn)
{:error, :closed} -> conn
{:error, _} -> conn
end
after
20_000 ->
now_unix = DateTime.utc_now() |> DateTime.to_unix()
case chunk(conn, ": ping #{now_unix}\n\n") do
{:ok, conn} -> loop_stream(conn)
{:error, :closed} -> conn
{:error, _} -> conn
end
end
end
end

View File

@@ -0,0 +1,57 @@
defmodule ShootingEventPhxWeb.FrontendController do
use ShootingEventPhxWeb, :controller
def serve(conn, %{"path" => segments}) do
request_path =
segments
|> List.wrap()
|> Enum.join("/")
|> String.trim_leading("/")
if String.starts_with?(request_path, "api") do
send_resp(conn, 404, "Not Found")
else
web_dir = web_dir()
requested = if request_path == "", do: "index.html", else: request_path
requested_file = safe_file_path(web_dir, requested)
cond do
requested_file != nil and File.regular?(requested_file) ->
send_static_file(conn, requested_file)
File.regular?(Path.join(web_dir, "index.html")) ->
send_static_file(conn, Path.join(web_dir, "index.html"))
true ->
send_resp(conn, 404, "frontend build not found. run make build")
end
end
end
defp web_dir do
System.get_env("WEB_DIR") || Path.expand("../frontend/dist", File.cwd!())
end
defp safe_file_path(web_dir, request_path) do
cleaned = request_path |> Path.expand(web_dir)
root = Path.expand(web_dir)
if String.starts_with?(cleaned, root) do
cleaned
else
nil
end
end
defp send_static_file(conn, file_path) do
content_type =
case MIME.from_path(file_path) do
"" -> "application/octet-stream"
type -> type
end
conn
|> put_resp_content_type(content_type)
|> send_file(200, file_path)
end
end

View File

@@ -0,0 +1,50 @@
defmodule ShootingEventPhxWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :shooting_event_phx
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
key: "_shooting_event_phx_key",
signing_salt: "RrxJbij4",
same_site: "Lax"
]
# socket "/live", Phoenix.LiveView.Socket,
# websocket: [connect_info: [session: @session_options]],
# longpoll: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# When code reloading is disabled (e.g., in production),
# the `gzip` option is enabled to serve compressed
# static files generated by running `phx.digest`.
plug Plug.Static,
at: "/",
from: :shooting_event_phx,
gzip: not code_reloading?,
only: ShootingEventPhxWeb.static_paths(),
raise_on_missing_only: code_reloading?
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :shooting_event_phx
end
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug ShootingEventPhxWeb.Plugs.CORS
plug Plug.Session, @session_options
plug ShootingEventPhxWeb.Router
end

View File

@@ -0,0 +1,18 @@
defmodule ShootingEventPhxWeb.Plugs.AdminOnly do
@moduledoc false
import Plug.Conn
alias ShootingEventPhx.Auth
def init(opts), do: opts
def call(conn, _opts) do
if Auth.admin_request?(conn) do
conn
else
conn
|> put_status(:unauthorized)
|> Phoenix.Controller.json(%{"message" => "invalid or expired admin token"})
|> halt()
end
end
end

View File

@@ -0,0 +1,20 @@
defmodule ShootingEventPhxWeb.Plugs.CORS do
@moduledoc false
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
conn =
conn
|> put_resp_header("access-control-allow-origin", "*")
|> put_resp_header("access-control-allow-methods", "GET,POST,PUT,DELETE,OPTIONS")
|> put_resp_header("access-control-allow-headers", "content-type,authorization")
if conn.method == "OPTIONS" do
conn |> send_resp(204, "") |> halt()
else
conn
end
end
end

View File

@@ -0,0 +1,49 @@
defmodule ShootingEventPhxWeb.Router do
use ShootingEventPhxWeb, :router
pipeline :api_json do
plug :accepts, ["json"]
end
pipeline :frontend do
end
pipeline :admin_api do
plug ShootingEventPhxWeb.Plugs.AdminOnly
end
scope "/api", ShootingEventPhxWeb do
get "/events", EventsController, :stream
end
scope "/api", ShootingEventPhxWeb do
pipe_through :api_json
get "/health", ApiController, :health
get "/state", ApiController, :state
post "/admin/login", ApiController, :admin_login
scope "/admin" do
pipe_through :admin_api
post "/logout", ApiController, :admin_logout
get "/state", ApiController, :admin_state
put "/settings", ApiController, :update_settings
post "/players", ApiController, :create_player
post "/players/auto-group", ApiController, :auto_group_players
put "/players/:id", ApiController, :update_player
delete "/players/:id", ApiController, :delete_player
put "/scores/:stage/:id", ApiController, :update_score
put "/scores/:stage/:id/proof", ApiController, :update_score_proof
delete "/scores/:stage/:id/proof", ApiController, :delete_score_proof
post "/scores/:stage/:id/advice", ApiController, :score_advice
post "/scores/:stage/reset", ApiController, :reset_stage
end
end
scope "/", ShootingEventPhxWeb do
pipe_through :frontend
match :*, "/*path", FrontendController, :serve
end
end

View File

@@ -0,0 +1,93 @@
defmodule ShootingEventPhxWeb.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
# Telemetry poller will execute the given period measurements
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
# Add reporters as children of your supervision tree.
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# Phoenix Metrics
summary("phoenix.endpoint.start.system_time",
unit: {:native, :millisecond}
),
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.start.system_time",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.exception.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.stop.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.socket_connected.duration",
unit: {:native, :millisecond}
),
sum("phoenix.socket_drain.count"),
summary("phoenix.channel_joined.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_handled_in.duration",
tags: [:event],
unit: {:native, :millisecond}
),
# Database Metrics
summary("shooting_event_phx.repo.query.total_time",
unit: {:native, :millisecond},
description: "The sum of the other measurements"
),
summary("shooting_event_phx.repo.query.decode_time",
unit: {:native, :millisecond},
description: "The time spent decoding the data received from the database"
),
summary("shooting_event_phx.repo.query.query_time",
unit: {:native, :millisecond},
description: "The time spent executing the query"
),
summary("shooting_event_phx.repo.query.queue_time",
unit: {:native, :millisecond},
description: "The time spent waiting for a database connection"
),
summary("shooting_event_phx.repo.query.idle_time",
unit: {:native, :millisecond},
description:
"The time the connection spent waiting before being checked out for the query"
),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
]
end
defp periodic_measurements do
[
# A module, function and arguments to be invoked periodically.
# This function must call :telemetry.execute/3 and a metric must be added above.
# {ShootingEventPhxWeb, :count_users, []}
]
end
end

70
phoenix/mix.exs Normal file
View File

@@ -0,0 +1,70 @@
defmodule ShootingEventPhx.MixProject do
use Mix.Project
def project do
[
app: :shooting_event_phx,
version: "0.1.0",
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
listeners: [Phoenix.CodeReloader]
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {ShootingEventPhx.Application, []},
extra_applications: [:logger, :runtime_tools]
]
end
def cli do
[
preferred_envs: [precommit: :test]
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
[
{:phoenix, "~> 1.8.5"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"},
{:ecto_sqlite3, ">= 0.0.0"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:jason, "~> 1.2"},
{:req, "~> 0.5"},
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"}
]
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to install project dependencies and perform other setup tasks, run:
#
# $ mix setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
setup: ["deps.get", "ecto.setup"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
]
end
end

32
phoenix/mix.lock Normal file
View File

@@ -0,0 +1,32 @@
%{
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
}

View File

@@ -0,0 +1,4 @@
[
import_deps: [:ecto_sql],
inputs: ["*.exs"]
]

Some files were not shown because too many files have changed in this diff Show More