Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6df640bd5 | |||
| 6f8195cda1 | |||
| 2ccfac2d04 | |||
| 99d28c2114 | |||
| 5863574a78 | |||
| 8cda54548f |
@@ -2,8 +2,6 @@
|
|||||||
.gitignore
|
.gitignore
|
||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
**/node_modules
|
||||||
frontend/dist
|
|
||||||
bin
|
|
||||||
backend/data
|
backend/data
|
||||||
data
|
data
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
182
API_CHANGES.md
Normal file
182
API_CHANGES.md
Normal 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.
|
||||||
26
Dockerfile
26
Dockerfile
@@ -1,26 +1,18 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
|
# FROM --platform=$BUILDPLATFORM golang:1.24 AS backend-builder
|
||||||
WORKDIR /app/frontend
|
# WORKDIR /app/backend
|
||||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
# COPY backend/go.mod backend/go.sum ./
|
||||||
RUN corepack enable && pnpm install --frozen-lockfile
|
# RUN go mod download
|
||||||
COPY frontend/ ./
|
# COPY backend/ ./
|
||||||
RUN pnpm build
|
# ARG TARGETARCH
|
||||||
|
# RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} go build -trimpath -ldflags="-s -w" -o /out/shooting-event .
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.24 AS backend-builder
|
|
||||||
WORKDIR /app/backend
|
|
||||||
COPY backend/go.mod backend/go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY backend/ ./
|
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./web
|
|
||||||
ARG TARGETARCH
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} go build -trimpath -ldflags="-s -w" -o /out/shooting-event .
|
|
||||||
|
|
||||||
FROM --platform=$TARGETPLATFORM alpine:3.21
|
FROM --platform=$TARGETPLATFORM alpine:3.21
|
||||||
RUN addgroup -S app && adduser -S app -G app && apk add --no-cache ca-certificates
|
RUN addgroup -S app && adduser -S app -G app && apk add --no-cache ca-certificates
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=backend-builder /out/shooting-event /app/shooting-event
|
COPY bin/shooting-event-amd64 /app/shooting-event
|
||||||
COPY --from=backend-builder /app/backend/web /app/web
|
COPY frontend/dist /app/web
|
||||||
RUN mkdir -p /app/data && chown -R app:app /app
|
RUN mkdir -p /app/data && chown -R app:app /app
|
||||||
USER app
|
USER app
|
||||||
ENV PORT=8080
|
ENV PORT=8080
|
||||||
|
|||||||
44
Dockerfile.phoenix
Normal file
44
Dockerfile.phoenix
Normal 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"]
|
||||||
28
Makefile
28
Makefile
@@ -3,16 +3,18 @@ SHELL := /bin/bash
|
|||||||
PNPM ?= pnpm
|
PNPM ?= pnpm
|
||||||
GO ?= go
|
GO ?= go
|
||||||
CONTAINER_REG ?= repo.ssp-itinfra.com/admin
|
CONTAINER_REG ?= repo.ssp-itinfra.com/admin
|
||||||
IMAGE_NAME ?= shooting-event
|
IMAGE_NAME ?= shooting-event-2
|
||||||
|
PHOENIX_IMAGE_NAME ?= $(IMAGE_NAME)-phoenix
|
||||||
ARCH ?= amd64
|
ARCH ?= amd64
|
||||||
BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
BUILD_VERSION ?= $(shell git describe --tags --always --dirty)
|
BUILD_VERSION ?= $(shell git describe --tags --always --dirty)
|
||||||
|
|
||||||
.PHONY: install dev dev-backend dev-frontend build build-frontend build-backend docker-build docker-run clean
|
.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:
|
install:
|
||||||
cd frontend && $(PNPM) install
|
cd frontend && $(PNPM) install
|
||||||
cd backend && $(GO) mod tidy
|
cd backend && $(GO) mod tidy
|
||||||
|
cd phoenix && mix deps.get
|
||||||
|
|
||||||
dev-backend:
|
dev-backend:
|
||||||
cd backend && $(GO) run .
|
cd backend && $(GO) run .
|
||||||
@@ -34,12 +36,21 @@ build-backend:
|
|||||||
mkdir -p bin
|
mkdir -p bin
|
||||||
cd backend && $(GO) build -o ../bin/shooting-event .
|
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
|
build: build-frontend
|
||||||
rm -rf backend/web
|
rm -rf backend/web
|
||||||
mkdir -p backend/web
|
mkdir -p backend/web
|
||||||
cp -R frontend/dist/. backend/web/
|
cp -R frontend/dist/. backend/web/
|
||||||
$(MAKE) build-backend
|
$(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-build:
|
||||||
docker buildx build --load --platform=linux/$(ARCH) -t $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) .
|
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)-latest
|
||||||
@@ -52,7 +63,20 @@ docker-run:
|
|||||||
mkdir -p data
|
mkdir -p data
|
||||||
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)
|
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:
|
release:
|
||||||
|
${MAKE} build
|
||||||
$(MAKE) docker-build
|
$(MAKE) docker-build
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -34,29 +34,39 @@ Production-ready full-stack web app based on your original live score concept.
|
|||||||
8. Podium is determined automatically.
|
8. Podium is determined automatically.
|
||||||
9. If top-3 tie exists, podium tie-break stage appears.
|
9. If top-3 tie exists, podium tie-break stage appears.
|
||||||
|
|
||||||
## API Highlights
|
## API Documentation (OpenAPI)
|
||||||
|
|
||||||
Public:
|
Interactive docs are now built in:
|
||||||
|
|
||||||
- `GET /api/health`
|
- Swagger UI: `GET /api/docs`
|
||||||
- `GET /api/state`
|
- OpenAPI JSON: `GET /api/openapi.json`
|
||||||
|
|
||||||
Admin:
|
### Testing with Auth in Swagger UI
|
||||||
|
|
||||||
- `POST /api/admin/login`
|
1. Open `/api/docs`.
|
||||||
- `POST /api/admin/logout`
|
2. Use the **Login & Authorize** form at the top:
|
||||||
- `POST /api/admin/players`
|
- Username: `datwyler` (default)
|
||||||
- `PUT /api/admin/players/:id`
|
- Password: `datwyler` (default)
|
||||||
- `DELETE /api/admin/players/:id`
|
3. The page calls `POST /api/admin/login`, retrieves the token, and auto-authorizes Swagger.
|
||||||
- `PUT /api/admin/scores/:stage/:id`
|
4. Use **Try it out** on any admin endpoint.
|
||||||
- `POST /api/admin/scores/:stage/:id/advice`
|
|
||||||
- `POST /api/admin/scores/:stage/reset`
|
|
||||||
|
|
||||||
Stages:
|
### 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`
|
- `preliminary`
|
||||||
- `prelim_tiebreak`
|
|
||||||
- `final`
|
- `final`
|
||||||
|
- `prelim_tiebreak`
|
||||||
- `final_tiebreak`
|
- `final_tiebreak`
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func (a *App) loadCurrentScore(stage string, playerID int) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) buildAdviceResponse(stage string, playerID int, raw scoreAdviceModelResponse) ScoreAdviceResponse {
|
func (a *App) buildAdviceResponse(stage string, playerID int, raw scoreAdviceModelResponse) ScoreAdviceResponse {
|
||||||
advised := clampInt(raw.AdvisedScore, 0, 9999)
|
advised := clampInt(raw.AdvisedScore, 0, 100)
|
||||||
reason := strings.TrimSpace(raw.Reason)
|
reason := strings.TrimSpace(raw.Reason)
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
reason = "AI estimated the score from visible impacts."
|
reason = "AI estimated the score from visible impacts."
|
||||||
|
|||||||
145
backend/api_docs.go
Normal file
145
backend/api_docs.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -59,12 +59,31 @@ CREATE TABLE IF NOT EXISTS score_attachments (
|
|||||||
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
|
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 (
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL DEFAULT '',
|
value TEXT NOT NULL DEFAULT '',
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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
|
INSERT OR IGNORE INTO app_settings(key, value) VALUES
|
||||||
('view_proof_in_view', '0'),
|
('view_proof_in_view', '0'),
|
||||||
('live_active_view', 'group_live'),
|
('live_active_view', 'group_live'),
|
||||||
|
|||||||
@@ -198,6 +198,6 @@ func scoreAdvicePrompt(stage string, currentScore int) string {
|
|||||||
return fmt.Sprintf(`Target scoring assistant.
|
return fmt.Sprintf(`Target scoring assistant.
|
||||||
Stage: %s. Current score: %d.
|
Stage: %s. Current score: %d.
|
||||||
Return STRICT JSON only:
|
Return STRICT JSON only:
|
||||||
{"advisedScore":<int 0..9999>,"reason":"<max 18 words>"}
|
{"advisedScore":<int 0..100>,"reason":"<max 18 words>"}
|
||||||
Do not add markdown or extra fields.`, stage, currentScore)
|
Do not add markdown or extra fields.`, stage, currentScore)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,84 @@ func (a *App) handleUpdateAdminSettings(c echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, state)
|
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 {
|
func (a *App) handleCreatePlayer(c echo.Context) error {
|
||||||
var req PlayerCreateRequest
|
var req PlayerCreateRequest
|
||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
@@ -295,6 +373,77 @@ func (a *App) handleAutoGroupPlayers(c echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, state)
|
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 {
|
func (a *App) handleUpdateScore(c echo.Context) error {
|
||||||
stage, err := validateStage(c.Param("stage"))
|
stage, err := validateStage(c.Param("stage"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -310,8 +459,8 @@ func (a *App) handleUpdateScore(c echo.Context) error {
|
|||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
return writeError(c, http.StatusBadRequest, "invalid request body")
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
}
|
}
|
||||||
if req.Score < 0 || req.Score > 9999 {
|
if req.Score < 0 || req.Score > 100 {
|
||||||
return writeError(c, http.StatusBadRequest, "score must be between 0 and 9999")
|
return writeError(c, http.StatusBadRequest, "score must be between 0 and 100")
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := a.db.Exec(`
|
res, err := a.db.Exec(`
|
||||||
@@ -382,7 +531,9 @@ func resolveResetStages(stage string) ([]string, error) {
|
|||||||
case "preliminary":
|
case "preliminary":
|
||||||
return append([]string{}, preliminaryRoundStages...), nil
|
return append([]string{}, preliminaryRoundStages...), nil
|
||||||
case "final":
|
case "final":
|
||||||
return append([]string{}, finalRoundStages...), nil
|
stages := append([]string{}, finalRoundStages...)
|
||||||
|
stages = append(stages, "final_tiebreak")
|
||||||
|
return stages, nil
|
||||||
case "prelim_tiebreak", "final_tiebreak":
|
case "prelim_tiebreak", "final_tiebreak":
|
||||||
normalized, err := validateStage(stage)
|
normalized, err := validateStage(stage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -84,13 +84,30 @@ type FinalGroups struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StateResponse struct {
|
type StateResponse struct {
|
||||||
Competition CompetitionMeta `json:"competition"`
|
Competition CompetitionMeta `json:"competition"`
|
||||||
Players []Player `json:"players"`
|
Players []Player `json:"players"`
|
||||||
Scores map[string]map[string]int `json:"scores"`
|
Scores map[string]map[string]int `json:"scores"`
|
||||||
ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"`
|
ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"`
|
||||||
Settings AppSettings `json:"settings"`
|
PositionBoards map[string]map[string]PositionSlot `json:"positionBoards"`
|
||||||
Derived DerivedState `json:"derived"`
|
Settings AppSettings `json:"settings"`
|
||||||
ServerTime string `json:"serverTime"`
|
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 {
|
type AppSettings struct {
|
||||||
@@ -170,6 +187,16 @@ type AutoGroupPlayersRequest struct {
|
|||||||
Groups []string `json:"groups"`
|
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 {
|
type ScoreAdviceResponse struct {
|
||||||
Stage string `json:"stage"`
|
Stage string `json:"stage"`
|
||||||
PlayerID int `json:"playerId"`
|
PlayerID int `json:"playerId"`
|
||||||
@@ -177,3 +204,9 @@ type ScoreAdviceResponse struct {
|
|||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
GeneratedAt string `json:"generatedAt"`
|
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
1410
backend/openapi/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,10 @@ import (
|
|||||||
|
|
||||||
func registerAPIRoutes(e *echo.Echo, app *App) {
|
func registerAPIRoutes(e *echo.Echo, app *App) {
|
||||||
api := e.Group("/api")
|
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("/health", app.handleHealth)
|
||||||
api.GET("/state", app.handleGetState)
|
api.GET("/state", app.handleGetState)
|
||||||
api.GET("/events", app.handleEvents)
|
api.GET("/events", app.handleEvents)
|
||||||
@@ -18,9 +22,13 @@ func registerAPIRoutes(e *echo.Echo, app *App) {
|
|||||||
api.POST("/admin/login", app.handleAdminLogin)
|
api.POST("/admin/login", app.handleAdminLogin)
|
||||||
api.POST("/admin/logout", app.handleAdminLogout, app.adminOnly)
|
api.POST("/admin/logout", app.handleAdminLogout, app.adminOnly)
|
||||||
api.GET("/admin/state", app.handleGetState, 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.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", app.handleCreatePlayer, app.adminOnly)
|
||||||
api.POST("/admin/players/auto-group", app.handleAutoGroupPlayers, 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.PUT("/admin/players/:id", app.handleUpdatePlayer, app.adminOnly)
|
||||||
api.DELETE("/admin/players/:id", app.handleDeletePlayer, 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", app.handleUpdateScore, app.adminOnly)
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ func normalizeRotationPlayerCountInt(value int) int {
|
|||||||
|
|
||||||
func normalizeLiveActiveView(value string, fallback string) string {
|
func normalizeLiveActiveView(value string, fallback string) string {
|
||||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||||
case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "podium":
|
case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "final_overall", "podium":
|
||||||
return strings.TrimSpace(strings.ToLower(value))
|
return strings.TrimSpace(strings.ToLower(value))
|
||||||
default:
|
default:
|
||||||
if strings.TrimSpace(strings.ToLower(fallback)) != "" {
|
if strings.TrimSpace(strings.ToLower(fallback)) != "" {
|
||||||
|
|||||||
172
backend/stage_sessions.go
Normal file
172
backend/stage_sessions.go
Normal 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
|
||||||
|
}
|
||||||
244
backend/state.go
244
backend/state.go
@@ -8,6 +8,21 @@ import (
|
|||||||
"time"
|
"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) {
|
func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
|
||||||
players, err := a.readPlayers()
|
players, err := a.readPlayers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -38,17 +53,32 @@ func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
derived := computeDerived(players, scoreMap)
|
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{
|
response := StateResponse{
|
||||||
Competition: CompetitionMeta{
|
Competition: CompetitionMeta{
|
||||||
TitleAr: "بطولة دويتوايلر للرماية",
|
TitleAr: "بطولة دويتوايلر للرماية",
|
||||||
TitleEn: "Datwyler Shooting Event",
|
TitleEn: "Datwyler Shooting Event",
|
||||||
},
|
},
|
||||||
Players: players,
|
Players: players,
|
||||||
Scores: scoreMapToJSON(scoreMap),
|
Scores: scoreMapToJSON(scoreMap),
|
||||||
Settings: settings,
|
PositionBoards: positionBoardMapToJSON(positionBoards),
|
||||||
Derived: derived,
|
Settings: settings,
|
||||||
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
Derived: derived,
|
||||||
|
CurrentStage: currentStage,
|
||||||
|
LastStage: lastStage,
|
||||||
|
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
if includeScoreProofs {
|
if includeScoreProofs {
|
||||||
response.ScoreProofs = scoreProofMapToJSON(scoreProofs)
|
response.ScoreProofs = scoreProofMapToJSON(scoreProofs)
|
||||||
@@ -133,6 +163,210 @@ func scoreProofMapToJSON(proofMap map[string]map[int]string) map[string]map[stri
|
|||||||
return out
|
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 {
|
func (a *App) ensureScoreRows(players []Player) error {
|
||||||
if len(players) == 0 {
|
if len(players) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
BIN
frontend/public/arture.png
Normal file
BIN
frontend/public/arture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
frontend/public/datwyler_logo.png
Normal file
BIN
frontend/public/datwyler_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/logo_white.png
Normal file
BIN
frontend/public/logo_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
BIN
frontend/public/simon_logo.png
Normal file
BIN
frontend/public/simon_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-shell" :class="'lang-' + language">
|
<div class="app-shell" :class="['lang-' + language, 'mode-' + mode]">
|
||||||
<input ref="imageInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onImageSelected" />
|
<input ref="imageInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onImageSelected" />
|
||||||
<input ref="scoreProofInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onScoreProofSelected" />
|
<input ref="scoreProofInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onScoreProofSelected" />
|
||||||
|
|
||||||
@@ -8,9 +8,11 @@
|
|||||||
:competition-title="competitionTitle"
|
:competition-title="competitionTitle"
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
:language="language"
|
:language="language"
|
||||||
|
:is-fullscreen="isFullscreen"
|
||||||
:server-time="state.serverTime ? formatServerTime(state.serverTime) : ''"
|
:server-time="state.serverTime ? formatServerTime(state.serverTime) : ''"
|
||||||
@change-mode="mode = $event"
|
@change-mode="mode = $event"
|
||||||
@change-language="setLanguage"
|
@change-language="setLanguage"
|
||||||
|
@toggle-fullscreen="toggleFullscreen"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main class="page-content">
|
<main class="page-content">
|
||||||
@@ -69,18 +71,20 @@
|
|||||||
v-else-if="mode === 'live'"
|
v-else-if="mode === 'live'"
|
||||||
:t="t"
|
:t="t"
|
||||||
:live-settings="liveSettings"
|
:live-settings="liveSettings"
|
||||||
|
:position-boards="positionBoards"
|
||||||
:live-group-code="liveMonitorGroupCode"
|
:live-group-code="liveMonitorGroupCode"
|
||||||
:live-group-members="liveMonitorMembers"
|
:live-group-members="liveMonitorMembers"
|
||||||
|
:prelim-tie="prelimTie"
|
||||||
:prelim-tie-rows="prelimTieRows"
|
:prelim-tie-rows="prelimTieRows"
|
||||||
:preliminary-rows="preliminaryRows"
|
:preliminary-rows="preliminaryRows"
|
||||||
:finalists="finalists"
|
:finalists="finalists"
|
||||||
:live-final-group="liveMonitorFinalGroup"
|
:live-final-group="liveMonitorFinalGroup"
|
||||||
:live-final-rows="liveMonitorFinalRows"
|
:live-final-rows="liveMonitorFinalRows"
|
||||||
|
:final-overall-rows="finalRows"
|
||||||
:final-tie-rows="finalTieRows"
|
:final-tie-rows="finalTieRows"
|
||||||
:podium-ordered="podiumOrdered"
|
:podium-ordered="podiumOrdered"
|
||||||
:player-image="playerImage"
|
:player-image="playerImage"
|
||||||
:display-name="displayName"
|
:display-name="displayName"
|
||||||
:secondary-name="secondaryName"
|
|
||||||
:score-for="scoreFor"
|
:score-for="scoreFor"
|
||||||
:prelim-total="preliminaryTotalScore"
|
:prelim-total="preliminaryTotalScore"
|
||||||
:final-total="finalTotalScore"
|
:final-total="finalTotalScore"
|
||||||
@@ -110,6 +114,12 @@
|
|||||||
:admin-tab="adminTab"
|
:admin-tab="adminTab"
|
||||||
:view-proof-in-view="canViewProofs"
|
:view-proof-in-view="canViewProofs"
|
||||||
:live-settings="liveSettings"
|
:live-settings="liveSettings"
|
||||||
|
:stage-control="stageControl"
|
||||||
|
:stage-options="stageOptions"
|
||||||
|
:current-stage="currentStage"
|
||||||
|
:last-stage="lastStage"
|
||||||
|
:active-scoring-stage="currentScoringStageKey"
|
||||||
|
:active-scoring-group="currentScoringGroupKey"
|
||||||
:group-setup-input="groupSetupInput"
|
:group-setup-input="groupSetupInput"
|
||||||
:admin-group-cards="adminGroupCards"
|
:admin-group-cards="adminGroupCards"
|
||||||
:new-player="newPlayer"
|
:new-player="newPlayer"
|
||||||
@@ -129,10 +139,12 @@
|
|||||||
:final-total-score="finalTotalScore"
|
:final-total-score="finalTotalScore"
|
||||||
:prelim-tie="prelimTie"
|
:prelim-tie="prelimTie"
|
||||||
:final-tie="finalTie"
|
:final-tie="finalTie"
|
||||||
|
:position-boards="positionBoards"
|
||||||
:score-filters="scoreFilters"
|
:score-filters="scoreFilters"
|
||||||
:display-name="displayName"
|
:display-name="displayName"
|
||||||
:secondary-name="secondaryName"
|
:secondary-name="secondaryName"
|
||||||
:score-input-value="scoreInputValue"
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
:on-score-focus="onScoreFocus"
|
:on-score-focus="onScoreFocus"
|
||||||
:on-score-input="onScoreInput"
|
:on-score-input="onScoreInput"
|
||||||
:on-score-commit="onScoreCommit"
|
:on-score-commit="onScoreCommit"
|
||||||
@@ -146,6 +158,10 @@
|
|||||||
@logout="adminLogout"
|
@logout="adminLogout"
|
||||||
@toggle-view-proof="updateViewProofSetting"
|
@toggle-view-proof="updateViewProofSetting"
|
||||||
@update-live-setting="updateLiveModeSetting"
|
@update-live-setting="updateLiveModeSetting"
|
||||||
|
@update-stage-control="updateStageControl"
|
||||||
|
@start-stage="startCurrentStage"
|
||||||
|
@end-stage="endCurrentStage"
|
||||||
|
@navigate-current-stage="navigateToCurrentStage"
|
||||||
@change-admin-tab="adminTab = $event"
|
@change-admin-tab="adminTab = $event"
|
||||||
@update:group-setup-input="groupSetupInput = $event"
|
@update:group-setup-input="groupSetupInput = $event"
|
||||||
@save-group-setup="saveGroupSetup"
|
@save-group-setup="saveGroupSetup"
|
||||||
@@ -162,6 +178,8 @@
|
|||||||
@remove-player-image="removePlayerImage"
|
@remove-player-image="removePlayerImage"
|
||||||
@delete-player="deletePlayer"
|
@delete-player="deletePlayer"
|
||||||
@request-reset-stage="openResetStageModal"
|
@request-reset-stage="openResetStageModal"
|
||||||
|
@reset-final-group-by-seed="resetFinalGroupBySeed"
|
||||||
|
@save-position-board="savePositionBoard"
|
||||||
@update-score-filter="updateScoreFilter"
|
@update-score-filter="updateScoreFilter"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -216,12 +234,14 @@ const FALLBACK_SYNC_MS = 15000
|
|||||||
const STREAM_RETRY_MS = 1500
|
const STREAM_RETRY_MS = 1500
|
||||||
const PRELIM_ROUND_STAGES = ['prelim_r1', 'prelim_r2', 'prelim_r3']
|
const PRELIM_ROUND_STAGES = ['prelim_r1', 'prelim_r2', 'prelim_r3']
|
||||||
const FINAL_ROUND_STAGES = ['final_r1', 'final_r2']
|
const FINAL_ROUND_STAGES = ['final_r1', 'final_r2']
|
||||||
|
const POSITION_BOARDS = ['preliminary', 'final', 'prelim_tiebreak', 'final_tiebreak']
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const mode = ref('live')
|
const mode = ref('live')
|
||||||
const viewTab = ref('groups')
|
const viewTab = ref('groups')
|
||||||
const adminTab = ref('players')
|
const adminTab = ref('players')
|
||||||
const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar')
|
const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar')
|
||||||
|
const isFullscreen = ref(false)
|
||||||
const state = ref(createInitialState())
|
const state = ref(createInitialState())
|
||||||
const primaryGroups = ref(loadPrimaryGroups(localStorage))
|
const primaryGroups = ref(loadPrimaryGroups(localStorage))
|
||||||
|
|
||||||
@@ -243,6 +263,11 @@ const scoreFilters = reactive({
|
|||||||
prelim_tiebreak: '',
|
prelim_tiebreak: '',
|
||||||
final_tiebreak: '',
|
final_tiebreak: '',
|
||||||
})
|
})
|
||||||
|
const stageControl = reactive({
|
||||||
|
phase: 'preliminary',
|
||||||
|
group: '',
|
||||||
|
round: 1,
|
||||||
|
})
|
||||||
|
|
||||||
const liveGroupIndex = ref(0)
|
const liveGroupIndex = ref(0)
|
||||||
const liveMode = ref('rotate')
|
const liveMode = ref('rotate')
|
||||||
@@ -294,6 +319,14 @@ const finalRows = computed(() => state.value.derived?.finalRanking?.rows || [])
|
|||||||
const podiumRows = computed(() => state.value.derived?.podium || [])
|
const podiumRows = computed(() => state.value.derived?.podium || [])
|
||||||
const prelimTie = computed(() => state.value.derived?.preliminaryRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] })
|
const prelimTie = computed(() => state.value.derived?.preliminaryRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] })
|
||||||
const finalTie = computed(() => state.value.derived?.finalRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] })
|
const finalTie = computed(() => state.value.derived?.finalRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] })
|
||||||
|
const positionBoards = computed(() => {
|
||||||
|
const raw = state.value.positionBoards || {}
|
||||||
|
const out = {}
|
||||||
|
for (const board of POSITION_BOARDS) {
|
||||||
|
out[board] = raw[board] || {}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
const canViewProofs = computed(() => Boolean(state.value.settings?.viewProofInView))
|
const canViewProofs = computed(() => Boolean(state.value.settings?.viewProofInView))
|
||||||
const liveSettings = computed(() => {
|
const liveSettings = computed(() => {
|
||||||
const raw = state.value.settings?.liveMode || {}
|
const raw = state.value.settings?.liveMode || {}
|
||||||
@@ -316,6 +349,10 @@ const liveSettings = computed(() => {
|
|||||||
rotationPlayerCount: Number(raw.rotationPlayerCount) > 0 ? Number(raw.rotationPlayerCount) : 12,
|
rotationPlayerCount: Number(raw.rotationPlayerCount) > 0 ? Number(raw.rotationPlayerCount) : 12,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const currentStage = computed(() => state.value.current_stage || null)
|
||||||
|
const lastStage = computed(() => state.value.last_stage || null)
|
||||||
|
const currentScoringStageKey = computed(() => mapStageSessionToScoreStage(currentStage.value))
|
||||||
|
const currentScoringGroupKey = computed(() => mapStageSessionToScoreGroup(currentStage.value))
|
||||||
|
|
||||||
const viewTabs = computed(() => [
|
const viewTabs = computed(() => [
|
||||||
{ id: 'groups', label: t('tabs.groups') },
|
{ id: 'groups', label: t('tabs.groups') },
|
||||||
@@ -405,7 +442,8 @@ const liveGroupCode = computed(() => {
|
|||||||
const liveMembers = computed(() => {
|
const liveMembers = computed(() => {
|
||||||
const code = normalizedGroupCode(liveGroupCode.value)
|
const code = normalizedGroupCode(liveGroupCode.value)
|
||||||
if (!code) return []
|
if (!code) return []
|
||||||
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
||||||
|
return sortRowsByPreliminaryPosition(rows)
|
||||||
})
|
})
|
||||||
|
|
||||||
const liveMonitorGroupRotationCodes = computed(() => activeGroupCodes.value)
|
const liveMonitorGroupRotationCodes = computed(() => activeGroupCodes.value)
|
||||||
@@ -420,7 +458,8 @@ const liveMonitorGroupCode = computed(() => {
|
|||||||
const liveMonitorMembers = computed(() => {
|
const liveMonitorMembers = computed(() => {
|
||||||
const code = normalizedGroupCode(liveMonitorGroupCode.value)
|
const code = normalizedGroupCode(liveMonitorGroupCode.value)
|
||||||
if (!code) return []
|
if (!code) return []
|
||||||
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
||||||
|
return sortRowsByPreliminaryPosition(rows)
|
||||||
})
|
})
|
||||||
const liveMonitorFinalGroup = computed(() => {
|
const liveMonitorFinalGroup = computed(() => {
|
||||||
if (liveSettings.value.finalDisplayMode === 'fixed') {
|
if (liveSettings.value.finalDisplayMode === 'fixed') {
|
||||||
@@ -428,17 +467,32 @@ const liveMonitorFinalGroup = computed(() => {
|
|||||||
}
|
}
|
||||||
return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2
|
return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2
|
||||||
})
|
})
|
||||||
const liveMonitorFinalRows = computed(() => (liveMonitorFinalGroup.value === 2 ? finalGroup2.value : finalGroup1.value))
|
const finalBoardGroupedRows = computed(() => {
|
||||||
|
const sorted = sortRowsByBoardPosition('final', adminFinalRows.value)
|
||||||
|
const grouped = {}
|
||||||
|
for (const row of sorted) {
|
||||||
|
const key = positionSlotFor('final', row.playerId)?.groupKey || String(row.finalGroup || 1)
|
||||||
|
if (!grouped[key]) grouped[key] = []
|
||||||
|
grouped[key].push(row)
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
})
|
||||||
|
const liveMonitorFinalRows = computed(() => {
|
||||||
|
const key = String(liveMonitorFinalGroup.value === 2 ? 2 : 1)
|
||||||
|
return finalBoardGroupedRows.value[key] || []
|
||||||
|
})
|
||||||
const podiumOrdered = computed(() => [podiumRows.value[1] || null, podiumRows.value[0] || null, podiumRows.value[2] || null])
|
const podiumOrdered = computed(() => [podiumRows.value[1] || null, podiumRows.value[0] || null, podiumRows.value[2] || null])
|
||||||
|
|
||||||
const prelimTieRows = computed(() => {
|
const prelimTieRows = computed(() => {
|
||||||
const ids = new Set(prelimTie.value.playerIds || [])
|
const ids = new Set(prelimTie.value.playerIds || [])
|
||||||
return preliminaryRows.value.filter((row) => ids.has(row.playerId))
|
const rows = preliminaryRows.value.filter((row) => ids.has(row.playerId))
|
||||||
|
return sortRowsByBoardPosition('prelim_tiebreak', rows)
|
||||||
})
|
})
|
||||||
|
|
||||||
const finalTieRows = computed(() => {
|
const finalTieRows = computed(() => {
|
||||||
const ids = new Set(finalTie.value.playerIds || [])
|
const ids = new Set(finalTie.value.playerIds || [])
|
||||||
return finalRows.value.filter((row) => ids.has(row.playerId))
|
const rows = finalRows.value.filter((row) => ids.has(row.playerId))
|
||||||
|
return sortRowsByBoardPosition('final_tiebreak', rows)
|
||||||
})
|
})
|
||||||
|
|
||||||
const playerByID = computed(() => {
|
const playerByID = computed(() => {
|
||||||
@@ -479,8 +533,7 @@ const adminPreliminaryRows = computed(() => {
|
|||||||
rank: derived?.rank ?? 0,
|
rank: derived?.rank ?? 0,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
rows.sort(compareRowsByGroupThenId)
|
return sortRowsByBoardPosition('preliminary', rows)
|
||||||
return rows
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const adminPrelimTieRows = computed(() => {
|
const adminPrelimTieRows = computed(() => {
|
||||||
@@ -500,8 +553,7 @@ const adminPrelimTieRows = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
rows.sort(compareRowsByScoreGroupThenId)
|
return sortRowsByBoardPosition('prelim_tiebreak', rows)
|
||||||
return rows
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const adminFinalRows = computed(() => {
|
const adminFinalRows = computed(() => {
|
||||||
@@ -521,17 +573,7 @@ const adminFinalRows = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
rows.sort((a, b) => {
|
return sortRowsByBoardPosition('final', rows)
|
||||||
const groupA = Number(a.finalGroup || 0)
|
|
||||||
const groupB = Number(b.finalGroup || 0)
|
|
||||||
if (groupA !== groupB) return groupA - groupB
|
|
||||||
const seedA = Number(a.seed || 0)
|
|
||||||
const seedB = Number(b.seed || 0)
|
|
||||||
if (seedA !== seedB) return seedA - seedB
|
|
||||||
return a.playerId - b.playerId
|
|
||||||
})
|
|
||||||
|
|
||||||
return rows
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const adminFinalTieRows = computed(() => {
|
const adminFinalTieRows = computed(() => {
|
||||||
@@ -554,8 +596,29 @@ const adminFinalTieRows = computed(() => {
|
|||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
rows.sort(compareRowsByScoreGroupThenId)
|
return sortRowsByBoardPosition('final_tiebreak', rows)
|
||||||
return rows
|
})
|
||||||
|
|
||||||
|
const stageOptions = computed(() => {
|
||||||
|
const preliminaryGroups = collectUniqueStrings([
|
||||||
|
...assignableGroups.value,
|
||||||
|
...activeGroupCodes.value,
|
||||||
|
])
|
||||||
|
const finalGroups = collectUniqueStrings([
|
||||||
|
...Object.keys(finalBoardGroupedRows.value || {}),
|
||||||
|
'1',
|
||||||
|
'2',
|
||||||
|
]).filter((value) => value === '1' || value === '2')
|
||||||
|
|
||||||
|
const prelimTieGroups = collectUniqueStrings((adminPrelimTieRows.value || []).map((row) => String(row._boardGroupKey || row.scoreGroup || '1')))
|
||||||
|
const finalTieGroups = collectUniqueStrings((adminFinalTieRows.value || []).map((row) => String(row._boardGroupKey || row.scoreGroup || '1')))
|
||||||
|
|
||||||
|
return {
|
||||||
|
preliminary: preliminaryGroups,
|
||||||
|
prelim_tiebreak: prelimTieGroups.length > 0 ? prelimTieGroups : ['1'],
|
||||||
|
final: finalGroups.length > 0 ? finalGroups : ['1', '2'],
|
||||||
|
final_tiebreak: finalTieGroups.length > 0 ? finalTieGroups : ['1'],
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(language, (value) => {
|
watch(language, (value) => {
|
||||||
@@ -639,7 +702,20 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => `${stageControl.phase}|${JSON.stringify(stageOptions.value)}`,
|
||||||
|
() => {
|
||||||
|
applyStageControlDefaults()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
onFullscreenChange()
|
||||||
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
document.addEventListener('webkitfullscreenchange', onFullscreenChange)
|
||||||
|
document.addEventListener('mozfullscreenchange', onFullscreenChange)
|
||||||
|
document.addEventListener('MSFullscreenChange', onFullscreenChange)
|
||||||
document.documentElement.lang = language.value
|
document.documentElement.lang = language.value
|
||||||
document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr'
|
document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr'
|
||||||
await fetchState()
|
await fetchState()
|
||||||
@@ -647,6 +723,10 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
document.removeEventListener('webkitfullscreenchange', onFullscreenChange)
|
||||||
|
document.removeEventListener('mozfullscreenchange', onFullscreenChange)
|
||||||
|
document.removeEventListener('MSFullscreenChange', onFullscreenChange)
|
||||||
stopLiveRotation()
|
stopLiveRotation()
|
||||||
stopLiveMonitorRotation()
|
stopLiveMonitorRotation()
|
||||||
stopRealtimeSync()
|
stopRealtimeSync()
|
||||||
@@ -662,9 +742,60 @@ function setLanguage(next) {
|
|||||||
if (next === 'ar' || next === 'en') language.value = next
|
if (next === 'ar' || next === 'en') language.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFullscreenElement() {
|
||||||
|
return (
|
||||||
|
document.fullscreenElement ||
|
||||||
|
document.webkitFullscreenElement ||
|
||||||
|
document.mozFullScreenElement ||
|
||||||
|
document.msFullscreenElement ||
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFullscreenChange() {
|
||||||
|
isFullscreen.value = Boolean(getFullscreenElement())
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestFullscreenFor(element) {
|
||||||
|
if (!element) return Promise.resolve()
|
||||||
|
const fn =
|
||||||
|
element.requestFullscreen ||
|
||||||
|
element.webkitRequestFullscreen ||
|
||||||
|
element.mozRequestFullScreen ||
|
||||||
|
element.msRequestFullscreen
|
||||||
|
if (!fn) return Promise.resolve()
|
||||||
|
const result = fn.call(element)
|
||||||
|
if (result && typeof result.then === 'function') return result
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitFullscreen() {
|
||||||
|
const fn =
|
||||||
|
document.exitFullscreen ||
|
||||||
|
document.webkitExitFullscreen ||
|
||||||
|
document.mozCancelFullScreen ||
|
||||||
|
document.msExitFullscreen
|
||||||
|
if (!fn) return Promise.resolve()
|
||||||
|
const result = fn.call(document)
|
||||||
|
if (result && typeof result.then === 'function') return result
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
try {
|
||||||
|
if (getFullscreenElement()) {
|
||||||
|
await exitFullscreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await requestFullscreenFor(document.documentElement)
|
||||||
|
} catch {
|
||||||
|
// Ignore fullscreen errors, mostly caused by browser policy.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLiveActiveView(value) {
|
function normalizeLiveActiveView(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase()
|
const normalized = String(value || '').trim().toLowerCase()
|
||||||
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'podium'])
|
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'final_overall', 'podium'])
|
||||||
return allowed.has(normalized) ? normalized : 'group_live'
|
return allowed.has(normalized) ? normalized : 'group_live'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,6 +855,116 @@ function compareRowsByScoreGroupThenId(a, b) {
|
|||||||
return compareRowsByGroupThenId(a, b)
|
return compareRowsByGroupThenId(a, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function positionSlotFor(board, playerId) {
|
||||||
|
const boardMap = positionBoards.value?.[board] || {}
|
||||||
|
return boardMap[String(playerId)] || boardMap[playerId] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositionGroup(board, raw, fallback = '') {
|
||||||
|
if (board === 'preliminary') {
|
||||||
|
const normalized = normalizedGroupCode(raw || '')
|
||||||
|
if (normalized) return normalized
|
||||||
|
if (String(raw || '').trim().toUpperCase() === 'UNASSIGNED') return 'UNASSIGNED'
|
||||||
|
return fallback || 'UNASSIGNED'
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(String(raw || ''), 10)
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) return String(parsed)
|
||||||
|
return fallback || '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositionValue(raw, fallback = 1) {
|
||||||
|
const parsed = Number(raw)
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionGroupSortValue(board, groupKey) {
|
||||||
|
if (board === 'preliminary') {
|
||||||
|
if (groupKey === 'UNASSIGNED') return Number.MAX_SAFE_INTEGER - 1
|
||||||
|
const idx = assignableGroups.value.indexOf(groupKey)
|
||||||
|
if (idx >= 0) return idx
|
||||||
|
return Number.MAX_SAFE_INTEGER
|
||||||
|
}
|
||||||
|
return Number.parseInt(String(groupKey || '0'), 10) || Number.MAX_SAFE_INTEGER
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRowsByPreliminaryPosition(rows) {
|
||||||
|
return sortRowsByBoardPosition('preliminary', rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRowsByBoardPosition(board, rows) {
|
||||||
|
const list = Array.isArray(rows) ? rows.slice() : []
|
||||||
|
if (list.length <= 1) return list
|
||||||
|
|
||||||
|
const fallbackRows = list.slice()
|
||||||
|
if (board === 'preliminary') {
|
||||||
|
fallbackRows.sort(compareRowsByGroupThenId)
|
||||||
|
} else if (board === 'final') {
|
||||||
|
fallbackRows.sort((a, b) => {
|
||||||
|
const groupA = Number(a.finalGroup || 0)
|
||||||
|
const groupB = Number(b.finalGroup || 0)
|
||||||
|
if (groupA !== groupB) return groupA - groupB
|
||||||
|
const seedA = Number(a.seed || 0)
|
||||||
|
const seedB = Number(b.seed || 0)
|
||||||
|
if (seedA !== seedB) return seedA - seedB
|
||||||
|
return Number(a.playerId || a.id || 0) - Number(b.playerId || b.id || 0)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fallbackRows.sort(compareRowsByScoreGroupThenId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackMap = new Map()
|
||||||
|
const fallbackCountByGroup = new Map()
|
||||||
|
for (let index = 0; index < fallbackRows.length; index += 1) {
|
||||||
|
const row = fallbackRows[index]
|
||||||
|
const playerId = Number(row.playerId || row.id || 0)
|
||||||
|
let fallbackGroup = '1'
|
||||||
|
if (board === 'preliminary') {
|
||||||
|
fallbackGroup = normalizePositionGroup(board, row.groupCode, 'UNASSIGNED')
|
||||||
|
} else if (board === 'final') {
|
||||||
|
fallbackGroup = normalizePositionGroup(board, row.finalGroup, '1')
|
||||||
|
} else if (board === 'prelim_tiebreak' || board === 'final_tiebreak') {
|
||||||
|
const scoreGroup = Number.parseInt(String(row.scoreGroup || ''), 10)
|
||||||
|
const finalGroup = Number.parseInt(String(row.finalGroup || ''), 10)
|
||||||
|
if (Number.isFinite(scoreGroup) && scoreGroup > 0) {
|
||||||
|
fallbackGroup = String(scoreGroup)
|
||||||
|
} else if (Number.isFinite(finalGroup) && finalGroup > 0) {
|
||||||
|
fallbackGroup = String(finalGroup)
|
||||||
|
} else {
|
||||||
|
fallbackGroup = '1'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fallbackGroup = '1'
|
||||||
|
}
|
||||||
|
const nextPos = (fallbackCountByGroup.get(fallbackGroup) || 0) + 1
|
||||||
|
fallbackCountByGroup.set(fallbackGroup, nextPos)
|
||||||
|
fallbackMap.set(playerId, { groupKey: fallbackGroup, position: nextPos, index })
|
||||||
|
}
|
||||||
|
|
||||||
|
return list
|
||||||
|
.map((row) => {
|
||||||
|
const playerId = Number(row.playerId || row.id || 0)
|
||||||
|
const fallback = fallbackMap.get(playerId) || { groupKey: '1', position: Number.MAX_SAFE_INTEGER, index: Number.MAX_SAFE_INTEGER }
|
||||||
|
const slot = positionSlotFor(board, playerId)
|
||||||
|
const groupKey = normalizePositionGroup(board, slot?.groupKey, fallback.groupKey)
|
||||||
|
const position = normalizePositionValue(slot?.position, fallback.position)
|
||||||
|
return { row, groupKey, position, fallbackIndex: fallback.index }
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const groupOrderA = positionGroupSortValue(board, a.groupKey)
|
||||||
|
const groupOrderB = positionGroupSortValue(board, b.groupKey)
|
||||||
|
if (groupOrderA !== groupOrderB) return groupOrderA - groupOrderB
|
||||||
|
if (a.groupKey !== b.groupKey) return String(a.groupKey).localeCompare(String(b.groupKey))
|
||||||
|
if (a.position !== b.position) return a.position - b.position
|
||||||
|
return a.fallbackIndex - b.fallbackIndex
|
||||||
|
})
|
||||||
|
.map((entry) => ({
|
||||||
|
...entry.row,
|
||||||
|
_boardGroupKey: entry.groupKey,
|
||||||
|
_boardPosition: entry.position,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
function saveGroupSetup() {
|
function saveGroupSetup() {
|
||||||
const parsed = parseGroupList(groupSetupInput.value)
|
const parsed = parseGroupList(groupSetupInput.value)
|
||||||
primaryGroups.value = parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS
|
primaryGroups.value = parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS
|
||||||
@@ -765,6 +1006,55 @@ function updateScoreFilter({ stage, value }) {
|
|||||||
scoreFilters[stage] = value
|
scoreFilters[stage] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function savePositionBoard(payload) {
|
||||||
|
const board = String(payload?.board || '').trim().toLowerCase()
|
||||||
|
if (!POSITION_BOARDS.includes(board)) return
|
||||||
|
const slots = Array.isArray(payload?.slots) ? payload.slots : []
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.value = await api(`/admin/positions/${board}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
admin: true,
|
||||||
|
body: { slots },
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetFinalGroupBySeed() {
|
||||||
|
const ranked = [...adminFinalRows.value]
|
||||||
|
.filter((row) => Number(row.seed || 0) > 0)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const seedA = Number(a.seed || 0)
|
||||||
|
const seedB = Number(b.seed || 0)
|
||||||
|
if (seedA !== seedB) return seedA - seedB
|
||||||
|
return Number(a.playerId || 0) - Number(b.playerId || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (ranked.length === 0) {
|
||||||
|
showToast(t('labels.noFinalists'), 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const slots = ranked.map((row, index) => ({
|
||||||
|
playerId: row.playerId,
|
||||||
|
groupKey: index < 6 ? '1' : '2',
|
||||||
|
position: (index % 6) + 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.value = await api('/admin/positions/final', {
|
||||||
|
method: 'PUT',
|
||||||
|
admin: true,
|
||||||
|
body: { slots },
|
||||||
|
})
|
||||||
|
showToast(t('messages.saved'))
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function autoGroupEvenly() {
|
async function autoGroupEvenly() {
|
||||||
if (primaryGroups.value.length === 0) {
|
if (primaryGroups.value.length === 0) {
|
||||||
showToast(t('messages.noGroupsConfigured'), 'error')
|
showToast(t('messages.noGroupsConfigured'), 'error')
|
||||||
@@ -926,6 +1216,139 @@ async function updateLiveModeSetting(patch) {
|
|||||||
await updateAdminSettings({ liveMode: patch })
|
await updateAdminSettings({ liveMode: patch })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectUniqueStrings(values) {
|
||||||
|
const set = new Set()
|
||||||
|
for (const raw of values || []) {
|
||||||
|
const value = String(raw || '').trim()
|
||||||
|
if (!value) continue
|
||||||
|
set.add(value)
|
||||||
|
}
|
||||||
|
return [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageRoundMax(phase) {
|
||||||
|
if (phase === 'preliminary') return 3
|
||||||
|
if (phase === 'final') return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStageSessionToScoreStage(stage) {
|
||||||
|
if (!stage || typeof stage !== 'object') return ''
|
||||||
|
const phase = String(stage.phase || '').trim().toLowerCase()
|
||||||
|
const round = Number(stage.round || 1)
|
||||||
|
if (phase === 'preliminary') return `prelim_r${Math.min(3, Math.max(1, Math.floor(round || 1)))}`
|
||||||
|
if (phase === 'final') return `final_r${Math.min(2, Math.max(1, Math.floor(round || 1)))}`
|
||||||
|
if (phase === 'prelim_tiebreak') return 'prelim_tiebreak'
|
||||||
|
if (phase === 'final_tiebreak') return 'final_tiebreak'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStageSessionToScoreGroup(stage) {
|
||||||
|
if (!stage || typeof stage !== 'object') return ''
|
||||||
|
const phase = String(stage.phase || '').trim().toLowerCase()
|
||||||
|
const rawGroup = String(stage.group || '').trim()
|
||||||
|
if (!rawGroup) return ''
|
||||||
|
if (phase === 'preliminary') {
|
||||||
|
if (rawGroup.toUpperCase() === 'UNASSIGNED') return 'UNASSIGNED'
|
||||||
|
return normalizedGroupCode(rawGroup)
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(rawGroup, 10)
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) return String(parsed)
|
||||||
|
return rawGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStagePhaseToAdminTab(phase) {
|
||||||
|
const normalized = String(phase || '').trim().toLowerCase()
|
||||||
|
if (normalized === 'preliminary') return 'preliminary'
|
||||||
|
if (normalized === 'prelim_tiebreak') return 'prelimTie'
|
||||||
|
if (normalized === 'final') return 'final'
|
||||||
|
if (normalized === 'final_tiebreak') return 'finalTie'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStageControlDefaults() {
|
||||||
|
const phase = String(stageControl.phase || 'preliminary').trim().toLowerCase()
|
||||||
|
if (!['preliminary', 'prelim_tiebreak', 'final', 'final_tiebreak'].includes(phase)) {
|
||||||
|
stageControl.phase = 'preliminary'
|
||||||
|
} else {
|
||||||
|
stageControl.phase = phase
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxRound = stageRoundMax(stageControl.phase)
|
||||||
|
const round = Number(stageControl.round || 1)
|
||||||
|
stageControl.round = Math.min(maxRound, Math.max(1, Number.isFinite(round) ? Math.floor(round) : 1))
|
||||||
|
|
||||||
|
const options = stageOptions.value?.[stageControl.phase] || []
|
||||||
|
if (options.length === 0) {
|
||||||
|
stageControl.group = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const current = String(stageControl.group || '').trim()
|
||||||
|
if (!options.includes(current)) {
|
||||||
|
stageControl.group = options[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStageControl(patch) {
|
||||||
|
if (!patch || typeof patch !== 'object') return
|
||||||
|
if (Object.prototype.hasOwnProperty.call(patch, 'phase')) {
|
||||||
|
stageControl.phase = String(patch.phase || 'preliminary').trim().toLowerCase()
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(patch, 'group')) {
|
||||||
|
stageControl.group = String(patch.group || '').trim()
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(patch, 'round')) {
|
||||||
|
stageControl.round = Number(patch.round || 1)
|
||||||
|
}
|
||||||
|
applyStageControlDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCurrentStage() {
|
||||||
|
applyStageControlDefaults()
|
||||||
|
if (!stageControl.group) {
|
||||||
|
showToast(t('messages.noGroupForPhase'), 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
state.value = await api('/admin/stage/start', {
|
||||||
|
method: 'POST',
|
||||||
|
admin: true,
|
||||||
|
body: {
|
||||||
|
phase: stageControl.phase,
|
||||||
|
group: stageControl.group,
|
||||||
|
round: stageControl.round,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
showToast(t('messages.saved'))
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function endCurrentStage() {
|
||||||
|
try {
|
||||||
|
state.value = await api('/admin/stage/end', {
|
||||||
|
method: 'POST',
|
||||||
|
admin: true,
|
||||||
|
})
|
||||||
|
showToast(t('messages.saved'))
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToCurrentStage() {
|
||||||
|
const active = currentStage.value
|
||||||
|
if (!active) {
|
||||||
|
showToast(t('labels.noCurrentStage'), 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const nextTab = mapStagePhaseToAdminTab(active.phase)
|
||||||
|
if (nextTab) {
|
||||||
|
adminTab.value = nextTab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createPlayer() {
|
async function createPlayer() {
|
||||||
if (!newPlayer.nameAr || !newPlayer.nameEn) {
|
if (!newPlayer.nameAr || !newPlayer.nameEn) {
|
||||||
showToast(t('messages.mustProvideNames'), 'error')
|
showToast(t('messages.mustProvideNames'), 'error')
|
||||||
@@ -1209,7 +1632,7 @@ async function applySuggestedScore() {
|
|||||||
if (!scoreAdviceModal.advice) return
|
if (!scoreAdviceModal.advice) return
|
||||||
const stage = scoreAdviceModal.stage
|
const stage = scoreAdviceModal.stage
|
||||||
const playerId = scoreAdviceModal.playerId
|
const playerId = scoreAdviceModal.playerId
|
||||||
const score = Number(scoreAdviceModal.advice.advisedScore || 0)
|
const score = clampScoreValue(scoreAdviceModal.advice.advisedScore)
|
||||||
await updateScoreValue(stage, playerId, score)
|
await updateScoreValue(stage, playerId, score)
|
||||||
scoreAdviceModal.currentScore = score
|
scoreAdviceModal.currentScore = score
|
||||||
closeScoreAdviceModal()
|
closeScoreAdviceModal()
|
||||||
@@ -1287,26 +1710,48 @@ function scoreDraftKey(stage, playerId) {
|
|||||||
return `${stage}:${playerId}`
|
return `${stage}:${playerId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampScoreValue(value) {
|
||||||
|
let score = Number(value)
|
||||||
|
if (!Number.isFinite(score)) score = 0
|
||||||
|
score = Math.trunc(score)
|
||||||
|
if (score < 0) return 0
|
||||||
|
if (score > 100) return 100
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
function scoreInputValue(stage, playerId) {
|
function scoreInputValue(stage, playerId) {
|
||||||
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
|
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
|
||||||
if (draft) return draft.value
|
if (draft) return draft.value
|
||||||
return String(scoreFor(stage, playerId))
|
return String(scoreFor(stage, playerId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scoreInputInvalid(stage, playerId) {
|
||||||
|
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
|
||||||
|
return Boolean(draft?.invalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDraftScoreValue(value) {
|
||||||
|
let score = Number(value === '' ? 0 : value)
|
||||||
|
if (!Number.isFinite(score)) score = 0
|
||||||
|
return Math.trunc(score)
|
||||||
|
}
|
||||||
|
|
||||||
function onScoreFocus(stage, playerId) {
|
function onScoreFocus(stage, playerId) {
|
||||||
const key = scoreDraftKey(stage, playerId)
|
const key = scoreDraftKey(stage, playerId)
|
||||||
if (!scoreDrafts[key]) {
|
if (!scoreDrafts[key]) {
|
||||||
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false }
|
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onScoreInput(stage, playerId, event) {
|
function onScoreInput(stage, playerId, event) {
|
||||||
const key = scoreDraftKey(stage, playerId)
|
const key = scoreDraftKey(stage, playerId)
|
||||||
const raw = String(event?.target?.value ?? '')
|
const raw = String(event?.target?.value ?? '')
|
||||||
const cleaned = raw.replace(/[^\d]/g, '').slice(0, 4)
|
const digits = raw.replace(/[^\d]/g, '').slice(0, 3)
|
||||||
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false }
|
const parsed = parseDraftScoreValue(digits)
|
||||||
scoreDrafts[key] = { ...current, value: cleaned }
|
const invalid = digits !== '' && (parsed < 0 || parsed > 100)
|
||||||
if (event?.target && event.target.value !== cleaned) event.target.value = cleaned
|
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
|
||||||
|
scoreDrafts[key] = { ...current, value: digits, invalid }
|
||||||
|
if (event?.target && event.target.value !== digits) event.target.value = digits
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onScoreCommit(stage, playerId) {
|
async function onScoreCommit(stage, playerId) {
|
||||||
@@ -1314,11 +1759,11 @@ async function onScoreCommit(stage, playerId) {
|
|||||||
const draft = scoreDrafts[key]
|
const draft = scoreDrafts[key]
|
||||||
if (!draft || draft.committing) return
|
if (!draft || draft.committing) return
|
||||||
|
|
||||||
let score = Number(draft.value === '' ? 0 : draft.value)
|
const score = parseDraftScoreValue(draft.value)
|
||||||
if (!Number.isFinite(score)) score = 0
|
const invalid = score < 0 || score > 100
|
||||||
score = Math.trunc(score)
|
|
||||||
|
|
||||||
if (score < 0 || score > 9999) {
|
if (invalid) {
|
||||||
|
scoreDrafts[key] = { ...draft, invalid: true }
|
||||||
showToast(t('messages.invalidScore'), 'error')
|
showToast(t('messages.invalidScore'), 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1328,12 +1773,12 @@ async function onScoreCommit(stage, playerId) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scoreDrafts[key] = { ...draft, committing: true }
|
scoreDrafts[key] = { ...draft, invalid: false, committing: true }
|
||||||
await updateScoreValue(stage, playerId, score, key)
|
await updateScoreValue(stage, playerId, score, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateScoreValue(stage, playerId, score, draftKey = '') {
|
async function updateScoreValue(stage, playerId, score, draftKey = '') {
|
||||||
if (score < 0 || score > 9999) {
|
if (score < 0 || score > 100) {
|
||||||
showToast(t('messages.invalidScore'), 'error')
|
showToast(t('messages.invalidScore'), 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,20 @@
|
|||||||
<h2>{{ t('liveMode') }}</h2>
|
<h2>{{ t('liveMode') }}</h2>
|
||||||
<p>{{ t('labels.liveModeVisibility') }}</p>
|
<p>{{ t('labels.liveModeVisibility') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<LiveSettingsCard :t="t" :live-settings="liveSettings" :assignable-groups="assignableGroups" @update-live-setting="$emit('update-live-setting', $event)" />
|
<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>
|
||||||
|
|
||||||
<section v-show="adminTab === 'preliminary'" class="panel">
|
<section v-show="adminTab === 'preliminary'" class="panel">
|
||||||
@@ -81,6 +94,18 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<MultiRoundScoreEditor
|
||||||
:t="t"
|
:t="t"
|
||||||
:rows="preliminaryScoreRows"
|
:rows="preliminaryScoreRows"
|
||||||
@@ -92,12 +117,17 @@
|
|||||||
:total-score="preliminaryTotalScore"
|
:total-score="preliminaryTotalScore"
|
||||||
:show-filter="false"
|
:show-filter="false"
|
||||||
:filter-text="scoreFilters.preliminary"
|
:filter-text="scoreFilters.preliminary"
|
||||||
|
:section-by-group="true"
|
||||||
|
:overflow-limit="6"
|
||||||
|
:highlight-stage="activeScoringStage"
|
||||||
|
:highlight-group="activeScoringGroup"
|
||||||
:show-group="true"
|
:show-group="true"
|
||||||
:show-rank="false"
|
:show-rank="false"
|
||||||
:player-image="playerImage"
|
:player-image="playerImage"
|
||||||
:display-name="displayName"
|
:display-name="displayName"
|
||||||
:secondary-name="secondaryName"
|
:secondary-name="secondaryName"
|
||||||
:score-input-value="scoreInputValue"
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
:on-score-focus="onScoreFocus"
|
:on-score-focus="onScoreFocus"
|
||||||
:on-score-input="onScoreInput"
|
:on-score-input="onScoreInput"
|
||||||
:on-score-commit="onScoreCommit"
|
:on-score-commit="onScoreCommit"
|
||||||
@@ -105,6 +135,7 @@
|
|||||||
:score-proof-for="scoreProofFor"
|
:score-proof-for="scoreProofFor"
|
||||||
:open-score-proof-uploader="openScoreProofUploader"
|
:open-score-proof-uploader="openScoreProofUploader"
|
||||||
:open-proof-preview="openProofPreview"
|
:open-proof-preview="openProofPreview"
|
||||||
|
:show-proof-controls="false"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -127,18 +158,33 @@
|
|||||||
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'prelim_tiebreak')">{{ t('actions.resetScores') }}</button>
|
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'prelim_tiebreak')">{{ t('actions.resetScores') }}</button>
|
||||||
</div>
|
</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
|
<ScoreStageEditor
|
||||||
:t="t"
|
:t="t"
|
||||||
stage="prelim_tiebreak"
|
stage="prelim_tiebreak"
|
||||||
color-mode="score"
|
color-mode="score"
|
||||||
:rows="prelimTieScoreRows"
|
:rows="prelimTieScoreRows"
|
||||||
:filter-text="scoreFilters.prelim_tiebreak"
|
:filter-text="scoreFilters.prelim_tiebreak"
|
||||||
|
:section-by-group="true"
|
||||||
|
:tie-break-section-mode="true"
|
||||||
:show-score-before-input="true"
|
:show-score-before-input="true"
|
||||||
:input-label="t('table.tieScore')"
|
:input-label="t('table.tieScore')"
|
||||||
:player-image="playerImage"
|
:player-image="playerImage"
|
||||||
:display-name="displayName"
|
:display-name="displayName"
|
||||||
:secondary-name="secondaryName"
|
:secondary-name="secondaryName"
|
||||||
:score-input-value="scoreInputValue"
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
:on-score-focus="onScoreFocus"
|
:on-score-focus="onScoreFocus"
|
||||||
:on-score-input="onScoreInput"
|
:on-score-input="onScoreInput"
|
||||||
:on-score-commit="onScoreCommit"
|
:on-score-commit="onScoreCommit"
|
||||||
@@ -148,6 +194,9 @@
|
|||||||
:remove-score-proof="removeScoreProof"
|
:remove-score-proof="removeScoreProof"
|
||||||
:open-proof-preview="openProofPreview"
|
:open-proof-preview="openProofPreview"
|
||||||
:request-score-advice="requestScoreAdvice"
|
: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 })"
|
@update:filter="$emit('update-score-filter', { stage: 'prelim_tiebreak', value: $event })"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -161,6 +210,7 @@
|
|||||||
|
|
||||||
<div class="panel-actions">
|
<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('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>
|
||||||
|
|
||||||
<div class="stage-filter-bar">
|
<div class="stage-filter-bar">
|
||||||
@@ -172,6 +222,18 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<MultiRoundScoreEditor
|
||||||
:t="t"
|
:t="t"
|
||||||
:rows="finalScoreRows"
|
:rows="finalScoreRows"
|
||||||
@@ -182,12 +244,17 @@
|
|||||||
:total-score="finalTotalScore"
|
:total-score="finalTotalScore"
|
||||||
:show-filter="false"
|
:show-filter="false"
|
||||||
:filter-text="scoreFilters.final_common"
|
:filter-text="scoreFilters.final_common"
|
||||||
|
:section-by-group="true"
|
||||||
|
:overflow-limit="6"
|
||||||
|
:highlight-stage="activeScoringStage"
|
||||||
|
:highlight-group="activeScoringGroup"
|
||||||
:show-group="true"
|
:show-group="true"
|
||||||
:show-seed="true"
|
:show-seed="true"
|
||||||
:player-image="playerImage"
|
:player-image="playerImage"
|
||||||
:display-name="displayName"
|
:display-name="displayName"
|
||||||
:secondary-name="secondaryName"
|
:secondary-name="secondaryName"
|
||||||
:score-input-value="scoreInputValue"
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
:on-score-focus="onScoreFocus"
|
:on-score-focus="onScoreFocus"
|
||||||
:on-score-input="onScoreInput"
|
:on-score-input="onScoreInput"
|
||||||
:on-score-commit="onScoreCommit"
|
:on-score-commit="onScoreCommit"
|
||||||
@@ -195,6 +262,7 @@
|
|||||||
:score-proof-for="scoreProofFor"
|
:score-proof-for="scoreProofFor"
|
||||||
:open-score-proof-uploader="openScoreProofUploader"
|
:open-score-proof-uploader="openScoreProofUploader"
|
||||||
:open-proof-preview="openProofPreview"
|
:open-proof-preview="openProofPreview"
|
||||||
|
:show-proof-controls="false"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -207,10 +275,22 @@
|
|||||||
<div class="hint-box danger" v-if="finalTie.required && !finalTie.resolved">{{ t('messages.finalTieUnresolved') }}</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="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">
|
<template v-if="finalTie.required">
|
||||||
<div class="panel-actions">
|
<PositionBoardEditor
|
||||||
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final_tiebreak')">{{ t('actions.resetScores') }}</button>
|
:t="t"
|
||||||
</div>
|
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
|
<ScoreStageEditor
|
||||||
:t="t"
|
:t="t"
|
||||||
@@ -218,12 +298,15 @@
|
|||||||
color-mode="score"
|
color-mode="score"
|
||||||
:rows="finalTieScoreRows"
|
:rows="finalTieScoreRows"
|
||||||
:filter-text="scoreFilters.final_tiebreak"
|
:filter-text="scoreFilters.final_tiebreak"
|
||||||
|
:section-by-group="true"
|
||||||
|
:tie-break-section-mode="true"
|
||||||
:show-score-before-input="true"
|
:show-score-before-input="true"
|
||||||
:input-label="t('table.tieScore')"
|
:input-label="t('table.tieScore')"
|
||||||
:player-image="playerImage"
|
:player-image="playerImage"
|
||||||
:display-name="displayName"
|
:display-name="displayName"
|
||||||
:secondary-name="secondaryName"
|
:secondary-name="secondaryName"
|
||||||
:score-input-value="scoreInputValue"
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
:on-score-focus="onScoreFocus"
|
:on-score-focus="onScoreFocus"
|
||||||
:on-score-input="onScoreInput"
|
:on-score-input="onScoreInput"
|
||||||
:on-score-commit="onScoreCommit"
|
:on-score-commit="onScoreCommit"
|
||||||
@@ -233,6 +316,9 @@
|
|||||||
:remove-score-proof="removeScoreProof"
|
:remove-score-proof="removeScoreProof"
|
||||||
:open-proof-preview="openProofPreview"
|
:open-proof-preview="openProofPreview"
|
||||||
:request-score-advice="requestScoreAdvice"
|
: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 })"
|
@update:filter="$emit('update-score-filter', { stage: 'final_tiebreak', value: $event })"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -283,6 +369,7 @@ import PlayersManagementTab from './admin/PlayersManagementTab.vue'
|
|||||||
import ScoreStageEditor from './admin/ScoreStageEditor.vue'
|
import ScoreStageEditor from './admin/ScoreStageEditor.vue'
|
||||||
import LiveSettingsCard from './admin/LiveSettingsCard.vue'
|
import LiveSettingsCard from './admin/LiveSettingsCard.vue'
|
||||||
import MultiRoundScoreEditor from './admin/MultiRoundScoreEditor.vue'
|
import MultiRoundScoreEditor from './admin/MultiRoundScoreEditor.vue'
|
||||||
|
import PositionBoardEditor from './admin/PositionBoardEditor.vue'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
t: { type: Function, required: true },
|
t: { type: Function, required: true },
|
||||||
@@ -290,6 +377,12 @@ defineProps({
|
|||||||
adminTab: { type: String, required: true },
|
adminTab: { type: String, required: true },
|
||||||
viewProofInView: { type: Boolean, default: false },
|
viewProofInView: { type: Boolean, default: false },
|
||||||
liveSettings: { type: Object, required: true },
|
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: '' },
|
groupSetupInput: { type: String, default: '' },
|
||||||
adminGroupCards: { type: Array, required: true },
|
adminGroupCards: { type: Array, required: true },
|
||||||
newPlayer: { type: Object, required: true },
|
newPlayer: { type: Object, required: true },
|
||||||
@@ -309,10 +402,12 @@ defineProps({
|
|||||||
finalTotalScore: { type: Function, required: true },
|
finalTotalScore: { type: Function, required: true },
|
||||||
prelimTie: { type: Object, required: true },
|
prelimTie: { type: Object, required: true },
|
||||||
finalTie: { type: Object, required: true },
|
finalTie: { type: Object, required: true },
|
||||||
|
positionBoards: { type: Object, required: true },
|
||||||
scoreFilters: { type: Object, required: true },
|
scoreFilters: { type: Object, required: true },
|
||||||
displayName: { type: Function, required: true },
|
displayName: { type: Function, required: true },
|
||||||
secondaryName: { type: Function, required: true },
|
secondaryName: { type: Function, required: true },
|
||||||
scoreInputValue: { type: Function, required: true },
|
scoreInputValue: { type: Function, required: true },
|
||||||
|
scoreInputInvalid: { type: Function, required: true },
|
||||||
onScoreFocus: { type: Function, required: true },
|
onScoreFocus: { type: Function, required: true },
|
||||||
onScoreInput: { type: Function, required: true },
|
onScoreInput: { type: Function, required: true },
|
||||||
onScoreCommit: { type: Function, required: true },
|
onScoreCommit: { type: Function, required: true },
|
||||||
@@ -329,6 +424,10 @@ defineEmits([
|
|||||||
'logout',
|
'logout',
|
||||||
'toggle-view-proof',
|
'toggle-view-proof',
|
||||||
'update-live-setting',
|
'update-live-setting',
|
||||||
|
'update-stage-control',
|
||||||
|
'start-stage',
|
||||||
|
'end-stage',
|
||||||
|
'navigate-current-stage',
|
||||||
'change-admin-tab',
|
'change-admin-tab',
|
||||||
'update:group-setup-input',
|
'update:group-setup-input',
|
||||||
'save-group-setup',
|
'save-group-setup',
|
||||||
@@ -345,6 +444,8 @@ defineEmits([
|
|||||||
'remove-player-image',
|
'remove-player-image',
|
||||||
'delete-player',
|
'delete-player',
|
||||||
'request-reset-stage',
|
'request-reset-stage',
|
||||||
|
'reset-final-group-by-seed',
|
||||||
|
'save-position-board',
|
||||||
'update-score-filter',
|
'update-score-filter',
|
||||||
])
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<div class="masthead-main">
|
<div class="masthead-main">
|
||||||
<div class="masthead-brand">
|
<div class="masthead-brand">
|
||||||
<picture>
|
<picture>
|
||||||
<source srcset="/logo.svg" type="image/svg+xml" />
|
<source v-if="mode !== 'live'" srcset="/logo.svg" type="image/svg+xml" />
|
||||||
<img class="masthead-logo" src="/logo.png" :alt="competitionTitle" />
|
<img class="masthead-logo" :src="mode === 'live' ? '/logo_white.png' : '/logo.png'" :alt="competitionTitle" />
|
||||||
</picture>
|
</picture>
|
||||||
<p class="masthead-subtitle">{{ t('subtitle') }}</p>
|
<p class="masthead-subtitle">{{ t('subtitle') }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,6 +16,35 @@
|
|||||||
<span>{{ optionsLabel }}</span>
|
<span>{{ optionsLabel }}</span>
|
||||||
<span class="control-toggle-meta">{{ modeShort }} · {{ languageShort }}</span>
|
<span class="control-toggle-meta">{{ modeShort }} · {{ languageShort }}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Transition name="controls-reveal">
|
<Transition name="controls-reveal">
|
||||||
@@ -23,7 +52,7 @@
|
|||||||
<div class="control-box">
|
<div class="control-box">
|
||||||
<p class="control-label">{{ t('labels.mode') }}</p>
|
<p class="control-label">{{ t('labels.mode') }}</p>
|
||||||
<div class="language-switcher mode-switcher">
|
<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 === '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 === 'live' }" @click="changeMode('live')">{{ t('liveMode') }}</button>
|
||||||
<button class="lang-btn" :class="{ active: mode === 'admin' }" @click="changeMode('admin')">{{ t('adminMode') }}</button>
|
<button class="lang-btn" :class="{ active: mode === 'admin' }" @click="changeMode('admin')">{{ t('adminMode') }}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,9 +86,10 @@ const props = defineProps({
|
|||||||
mode: { type: String, required: true },
|
mode: { type: String, required: true },
|
||||||
language: { type: String, required: true },
|
language: { type: String, required: true },
|
||||||
serverTime: { type: String, default: '' },
|
serverTime: { type: String, default: '' },
|
||||||
|
isFullscreen: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['change-mode', 'change-language'])
|
const emit = defineEmits(['change-mode', 'change-language', 'toggle-fullscreen'])
|
||||||
|
|
||||||
const controlsOpen = ref(false)
|
const controlsOpen = ref(false)
|
||||||
const controlsRoot = ref(null)
|
const controlsRoot = ref(null)
|
||||||
@@ -71,6 +101,7 @@ const modeShort = computed(() => {
|
|||||||
return props.language === 'ar' ? 'إدارة' : 'Admin'
|
return props.language === 'ar' ? 'إدارة' : 'Admin'
|
||||||
})
|
})
|
||||||
const languageShort = computed(() => (props.language === 'ar' ? 'AR' : 'EN'))
|
const languageShort = computed(() => (props.language === 'ar' ? 'AR' : 'EN'))
|
||||||
|
const fullscreenActionLabel = computed(() => (props.isFullscreen ? props.t('actions.exitFullscreen') : props.t('actions.enterFullscreen')))
|
||||||
const modeStatusLabel = computed(() => {
|
const modeStatusLabel = computed(() => {
|
||||||
if (props.mode === 'view') return '● ' + props.t('labels.liveTracker')
|
if (props.mode === 'view') return '● ' + props.t('labels.liveTracker')
|
||||||
if (props.mode === 'live') return '● ' + props.t('liveMode')
|
if (props.mode === 'live') return '● ' + props.t('liveMode')
|
||||||
@@ -91,6 +122,10 @@ function changeLanguage(nextLanguage) {
|
|||||||
controlsOpen.value = false
|
controlsOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleFullscreen() {
|
||||||
|
emit('toggle-fullscreen')
|
||||||
|
}
|
||||||
|
|
||||||
function onGlobalPointerDown(event) {
|
function onGlobalPointerDown(event) {
|
||||||
if (!controlsOpen.value) return
|
if (!controlsOpen.value) return
|
||||||
const root = controlsRoot.value
|
const root = controlsRoot.value
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="live-mode-shell">
|
<div class="live-mode-root">
|
||||||
<div class="live-mode-overlay">
|
<section class="live-mode-shell">
|
||||||
<header class="live-mode-header">
|
<div class="live-mode-overlay">
|
||||||
<h2>{{ t('sections.liveModeTitle') }}</h2>
|
<!-- <div class="live-screen-head live-head-selection">
|
||||||
<p>{{ t('sections.liveModeSubtitle') }}</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="live-screen-head live-head-selection">
|
|
||||||
<span class="live-chip strong">{{ activeViewLabel }}</span>
|
<span class="live-chip strong">{{ activeViewLabel }}</span>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<div class="live-mode-grid single">
|
<div class="live-mode-grid single">
|
||||||
<article v-if="activeView === 'group_live'" class="live-screen-card wide">
|
<article v-if="activeView === 'group_live'" class="live-screen-card wide">
|
||||||
@@ -30,7 +26,7 @@
|
|||||||
<table class="score-table live-score-table">
|
<table class="score-table live-score-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t('table.rank') }}</th>
|
<th>{{ t('table.position') }}</th>
|
||||||
<th>{{ t('table.competitor') }}</th>
|
<th>{{ t('table.competitor') }}</th>
|
||||||
<th>{{ t('table.round1') }}</th>
|
<th>{{ t('table.round1') }}</th>
|
||||||
<th>{{ t('table.round2') }}</th>
|
<th>{{ t('table.round2') }}</th>
|
||||||
@@ -39,14 +35,13 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="entry in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id">
|
<tr v-for="(entry, index) in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id">
|
||||||
<td class="mono rank">{{ entry.rank }}</td>
|
<td class="mono rank">{{ index + 1 }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="competitor-cell compact">
|
<div class="competitor-cell compact">
|
||||||
<img :src="playerImage(entry.player)" :alt="entry.player.nameAr" class="competitor-image" />
|
<img :src="playerImage(entry.player)" :alt="entry.player.nameAr" class="competitor-image" />
|
||||||
<div>
|
<div>
|
||||||
<p class="name-main">{{ displayName(entry.player) }}</p>
|
<p class="name-main">{{ displayName(entry.player) }}</p>
|
||||||
<p class="name-sub">{{ secondaryName(entry.player) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -68,41 +63,41 @@
|
|||||||
<h3>{{ t('sections.prelimTieTitle') }}</h3>
|
<h3>{{ t('sections.prelimTieTitle') }}</h3>
|
||||||
<div class="live-chip-row">
|
<div class="live-chip-row">
|
||||||
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
||||||
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
<span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition name="live-swap" mode="out-in">
|
<Transition name="live-swap" mode="out-in">
|
||||||
<div :key="'prelim-tie-page-' + listPageIndex" class="live-swap-block">
|
<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">
|
<div class="table-wrap">
|
||||||
<table class="score-table">
|
<table class="score-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>{{ t('table.position') }}</th>
|
||||||
<th>{{ t('table.competitor') }}</th>
|
<th>{{ t('table.competitor') }}</th>
|
||||||
<th>{{ t('table.tieScore') }}</th>
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(row, index) in pagedPrelimTieRows" :key="'live-pre-tie-' + row.playerId">
|
<tr v-for="entry in activePrelimTieGroup.rows" :key="'live-pre-tie-' + activePrelimTieGroup.groupKey + '-' + entry.row.playerId">
|
||||||
<td class="mono">{{ listPageStart + index + 1 }}</td>
|
<td class="mono">{{ entry.position }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="competitor-cell compact">
|
<div class="competitor-cell compact">
|
||||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
|
||||||
<div>
|
<div>
|
||||||
<p class="name-main">{{ displayName(row) }}</p>
|
<p class="name-main">{{ displayName(entry.row) }}</p>
|
||||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="mono strong">{{ row.tieBreak }}</td>
|
<td class="mono strong">{{ entry.row.tieBreak }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="prelimTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noPrelimTie') }}</td></tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
<div v-if="groupedPrelimTieRows.length === 0" class="muted center">{{ t('messages.noPrelimTie') }}</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article v-else-if="activeView === 'prelim_overall'" class="live-screen-card wide">
|
<article v-else-if="activeView === 'prelim_overall'" class="live-screen-card wide">
|
||||||
@@ -130,22 +125,25 @@
|
|||||||
<tr
|
<tr
|
||||||
v-for="row in pagedPreliminaryRows"
|
v-for="row in pagedPreliminaryRows"
|
||||||
:key="'live-pre-overall-' + row.playerId"
|
:key="'live-pre-overall-' + row.playerId"
|
||||||
:class="{ 'qualified-row': isFinalist(row.playerId) }"
|
|
||||||
>
|
>
|
||||||
<td class="mono rank">{{ row.rank }}</td>
|
<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>
|
<td>
|
||||||
<div class="competitor-cell compact">
|
<div class="competitor-cell compact">
|
||||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
<div>
|
<div>
|
||||||
<p class="name-main">{{ displayName(row) }}</p>
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="mono strong">{{ row.score }}</td>
|
<td class="mono strong">{{ row.score }}</td>
|
||||||
<td class="mono">{{ row.tieBreak }}</td>
|
<td class="mono">{{ row.tieBreak }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="preliminaryRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
|
<tr v-if="preliminaryRowsForLive.length === 0"><td colspan="4" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,15 +162,15 @@
|
|||||||
<Transition name="live-swap" mode="out-in">
|
<Transition name="live-swap" mode="out-in">
|
||||||
<div :key="'live-final-swap-' + liveFinalGroup" class="live-swap-block">
|
<div :key="'live-final-swap-' + liveFinalGroup" class="live-swap-block">
|
||||||
<div class="live-focus-banner focus-final">
|
<div class="live-focus-banner focus-final">
|
||||||
<span class="live-focus-label">{{ t('tabs.final') }}</span>
|
<span class="live-focus-label">{{ currentFinalSeedLabel }}</span>
|
||||||
<strong class="live-focus-title">{{ currentFinalGroupLabel }}</strong>
|
<strong class="live-focus-title">{{ currentFinalGroupNumber }}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="score-table live-score-table">
|
<table class="score-table live-score-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ t('table.rank') }}</th>
|
<th>{{ t('table.position') }}</th>
|
||||||
<th>{{ t('table.competitor') }}</th>
|
<th>{{ t('table.competitor') }}</th>
|
||||||
<th>{{ t('table.round1') }}</th>
|
<th>{{ t('table.round1') }}</th>
|
||||||
<th>{{ t('table.round2') }}</th>
|
<th>{{ t('table.round2') }}</th>
|
||||||
@@ -180,14 +178,13 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="entry in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId">
|
<tr v-for="(entry, index) in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId">
|
||||||
<td class="mono rank">{{ entry.rank }}</td>
|
<td class="mono rank">{{ index + 1 }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="competitor-cell compact">
|
<div class="competitor-cell compact">
|
||||||
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
|
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
|
||||||
<div>
|
<div>
|
||||||
<p class="name-main">{{ displayName(entry.row) }}</p>
|
<p class="name-main">{{ displayName(entry.row) }}</p>
|
||||||
<p class="name-sub">{{ secondaryName(entry.row) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -208,36 +205,82 @@
|
|||||||
<h3>{{ t('sections.finalTieTitle') }}</h3>
|
<h3>{{ t('sections.finalTieTitle') }}</h3>
|
||||||
<div class="live-chip-row">
|
<div class="live-chip-row">
|
||||||
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
||||||
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
<span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition name="live-swap" mode="out-in">
|
<Transition name="live-swap" mode="out-in">
|
||||||
<div :key="'final-tie-page-' + listPageIndex" class="live-swap-block">
|
<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">
|
<div class="table-wrap">
|
||||||
<table class="score-table">
|
<table class="score-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>{{ t('table.position') }}</th>
|
||||||
<th>{{ t('table.competitor') }}</th>
|
<th>{{ t('table.competitor') }}</th>
|
||||||
<th>{{ t('table.tieScore') }}</th>
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(row, index) in pagedFinalTieRows" :key="'live-final-tie-' + row.playerId">
|
<tr v-for="entry in activeFinalTieGroup.rows" :key="'live-final-tie-' + activeFinalTieGroup.groupKey + '-' + entry.row.playerId">
|
||||||
<td class="mono">{{ listPageStart + index + 1 }}</td>
|
<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>
|
<td>
|
||||||
<div class="competitor-cell compact">
|
<div class="competitor-cell compact">
|
||||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
<div>
|
<div>
|
||||||
<p class="name-main">{{ displayName(row) }}</p>
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="mono strong">{{ row.tieBreak }}</td>
|
<td class="mono strong">{{ row.score }}</td>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="finalTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noFinalTie') }}</td></tr>
|
<tr v-if="finalOverallRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,7 +299,7 @@
|
|||||||
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
|
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
|
||||||
</div>
|
</div>
|
||||||
<div class="podium-medal-icon">🥈</div>
|
<div class="podium-medal-icon">🥈</div>
|
||||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
|
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[0])" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
|
||||||
<div class="podium-score-wrap">
|
<div class="podium-score-wrap">
|
||||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
|
||||||
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
|
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
|
||||||
@@ -268,7 +311,7 @@
|
|||||||
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
|
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
|
||||||
</div>
|
</div>
|
||||||
<div class="podium-medal-icon">🥇</div>
|
<div class="podium-medal-icon">🥇</div>
|
||||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
|
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[1])" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
|
||||||
<div class="podium-score-wrap">
|
<div class="podium-score-wrap">
|
||||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
|
||||||
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
|
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
|
||||||
@@ -280,7 +323,7 @@
|
|||||||
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
|
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
|
||||||
</div>
|
</div>
|
||||||
<div class="podium-medal-icon">🥉</div>
|
<div class="podium-medal-icon">🥉</div>
|
||||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
|
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[2])" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
|
||||||
<div class="podium-score-wrap">
|
<div class="podium-score-wrap">
|
||||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
|
||||||
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
|
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
|
||||||
@@ -289,8 +332,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -299,18 +359,20 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
t: { type: Function, required: true },
|
t: { type: Function, required: true },
|
||||||
liveSettings: { type: Object, required: true },
|
liveSettings: { type: Object, required: true },
|
||||||
|
positionBoards: { type: Object, default: () => ({}) },
|
||||||
liveGroupCode: { type: String, default: '' },
|
liveGroupCode: { type: String, default: '' },
|
||||||
liveGroupMembers: { type: Array, required: true },
|
liveGroupMembers: { type: Array, required: true },
|
||||||
|
prelimTie: { type: Object, default: () => ({ required: false, resolved: true, slots: 0, playerIds: [] }) },
|
||||||
prelimTieRows: { type: Array, required: true },
|
prelimTieRows: { type: Array, required: true },
|
||||||
preliminaryRows: { type: Array, required: true },
|
preliminaryRows: { type: Array, required: true },
|
||||||
finalists: { type: Array, required: true },
|
finalists: { type: Array, required: true },
|
||||||
liveFinalGroup: { type: Number, required: true },
|
liveFinalGroup: { type: Number, required: true },
|
||||||
liveFinalRows: { type: Array, required: true },
|
liveFinalRows: { type: Array, required: true },
|
||||||
|
finalOverallRows: { type: Array, required: true },
|
||||||
finalTieRows: { type: Array, required: true },
|
finalTieRows: { type: Array, required: true },
|
||||||
podiumOrdered: { type: Array, required: true },
|
podiumOrdered: { type: Array, required: true },
|
||||||
playerImage: { type: Function, required: true },
|
playerImage: { type: Function, required: true },
|
||||||
displayName: { type: Function, required: true },
|
displayName: { type: Function, required: true },
|
||||||
secondaryName: { type: Function, required: true },
|
|
||||||
scoreFor: { type: Function, required: true },
|
scoreFor: { type: Function, required: true },
|
||||||
prelimTotal: { type: Function, required: true },
|
prelimTotal: { type: Function, required: true },
|
||||||
finalTotal: { type: Function, required: true },
|
finalTotal: { type: Function, required: true },
|
||||||
@@ -322,9 +384,14 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const finalistIds = computed(() => new Set((props.finalists || []).map((row) => row.playerId)))
|
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 activeView = computed(() => props.liveSettings.activeView || 'group_live')
|
||||||
const listPageIndex = ref(0)
|
const listPageIndex = ref(0)
|
||||||
|
const tieGroupIndex = ref(0)
|
||||||
let listRotationTimer = null
|
let listRotationTimer = null
|
||||||
|
let tieRotationTimer = null
|
||||||
|
|
||||||
const rotationIntervalSec = computed(() => {
|
const rotationIntervalSec = computed(() => {
|
||||||
const raw = Number(props.liveSettings.rotationIntervalSec || 5)
|
const raw = Number(props.liveSettings.rotationIntervalSec || 5)
|
||||||
@@ -340,10 +407,11 @@ const rotationPlayerCount = computed(() => {
|
|||||||
return Math.floor(raw)
|
return Math.floor(raw)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const preliminaryRowsForLive = computed(() => rankPreliminaryRowsWithTieBreak(props.preliminaryRows || []))
|
||||||
|
|
||||||
const listRowsForActiveView = computed(() => {
|
const listRowsForActiveView = computed(() => {
|
||||||
if (activeView.value === 'prelim_tie') return props.prelimTieRows || []
|
if (activeView.value === 'prelim_overall') return preliminaryRowsForLive.value
|
||||||
if (activeView.value === 'prelim_overall') return props.preliminaryRows || []
|
if (activeView.value === 'final_overall') return props.finalOverallRows || []
|
||||||
if (activeView.value === 'final_tie') return props.finalTieRows || []
|
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -353,30 +421,37 @@ const listPageCount = computed(() => {
|
|||||||
return Math.max(1, Math.ceil(total / rotationPlayerCount.value))
|
return Math.max(1, Math.ceil(total / rotationPlayerCount.value))
|
||||||
})
|
})
|
||||||
|
|
||||||
const listPageStart = computed(() => listPageIndex.value * rotationPlayerCount.value)
|
|
||||||
const currentListPageDisplay = computed(() => `${listPageIndex.value + 1} / ${listPageCount.value}`)
|
const currentListPageDisplay = computed(() => `${listPageIndex.value + 1} / ${listPageCount.value}`)
|
||||||
|
|
||||||
const pagedPrelimTieRows = computed(() => paginateRows(props.prelimTieRows || []))
|
const pagedPreliminaryRows = computed(() => paginateRows(preliminaryRowsForLive.value))
|
||||||
const pagedPreliminaryRows = computed(() => paginateRows(props.preliminaryRows || []))
|
const pagedFinalOverallRows = computed(() => paginateRows(props.finalOverallRows || []))
|
||||||
const pagedFinalTieRows = computed(() => paginateRows(props.finalTieRows || []))
|
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(() => {
|
const rankedLiveGroupMembers = computed(() => {
|
||||||
const rows = (props.liveGroupMembers || []).map((player) => ({
|
return (props.liveGroupMembers || []).map((player) => ({
|
||||||
player,
|
player,
|
||||||
total: Number(props.prelimTotal(player.id) || 0),
|
total: Number(props.prelimTotal(player.id) || 0),
|
||||||
}))
|
}))
|
||||||
rows.sort((a, b) => {
|
|
||||||
if (a.total !== b.total) return b.total - a.total
|
|
||||||
return a.player.id - b.player.id
|
|
||||||
})
|
|
||||||
|
|
||||||
let prevTotal = null
|
|
||||||
let prevRank = 0
|
|
||||||
return rows.map((entry, idx) => {
|
|
||||||
const rank = prevTotal === entry.total ? prevRank : idx + 1
|
|
||||||
prevTotal = entry.total
|
|
||||||
prevRank = rank
|
|
||||||
return { ...entry, rank }
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeViewLabel = computed(() => {
|
const activeViewLabel = computed(() => {
|
||||||
@@ -386,15 +461,17 @@ const activeViewLabel = computed(() => {
|
|||||||
prelim_overall: props.t('labels.liveViewPrelimOverall'),
|
prelim_overall: props.t('labels.liveViewPrelimOverall'),
|
||||||
final_groups: props.t('labels.liveViewFinalGroups'),
|
final_groups: props.t('labels.liveViewFinalGroups'),
|
||||||
final_tie: props.t('labels.liveViewFinalTie'),
|
final_tie: props.t('labels.liveViewFinalTie'),
|
||||||
|
final_overall: props.t('labels.liveViewFinalOverall'),
|
||||||
podium: props.t('labels.liveViewPodium'),
|
podium: props.t('labels.liveViewPodium'),
|
||||||
}
|
}
|
||||||
return map[activeView.value] || map.group_live
|
return map[activeView.value] || map.group_live
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentFinalGroupLabel = computed(() => {
|
const currentFinalSeedLabel = computed(() => {
|
||||||
if (props.liveFinalGroup === 2) return props.t('labels.finalGroup2')
|
if (props.liveFinalGroup === 2) return props.t('labels.finalGroupSeeds2')
|
||||||
return props.t('labels.finalGroup1')
|
return props.t('labels.finalGroupSeeds1')
|
||||||
})
|
})
|
||||||
|
const currentFinalGroupNumber = computed(() => (props.liveFinalGroup === 2 ? '2' : '1'))
|
||||||
const groupClassKey = computed(() => {
|
const groupClassKey = computed(() => {
|
||||||
const code = String(props.liveGroupCode || '').trim().toUpperCase()
|
const code = String(props.liveGroupCode || '').trim().toUpperCase()
|
||||||
if (code.startsWith('A')) return 'a'
|
if (code.startsWith('A')) return 'a'
|
||||||
@@ -404,44 +481,48 @@ const groupClassKey = computed(() => {
|
|||||||
return 'u'
|
return 'u'
|
||||||
})
|
})
|
||||||
const rankedLiveFinalRows = computed(() => {
|
const rankedLiveFinalRows = computed(() => {
|
||||||
const rows = (props.liveFinalRows || []).map((row) => ({
|
return (props.liveFinalRows || []).map((row) => ({
|
||||||
row,
|
row,
|
||||||
total: Number(props.finalTotal(row.playerId) || 0),
|
total: Number(props.finalTotal(row.playerId) || 0),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
rows.sort((a, b) => {
|
|
||||||
if (a.total !== b.total) return b.total - a.total
|
|
||||||
const seedA = Number(a.row.seed || 0)
|
|
||||||
const seedB = Number(b.row.seed || 0)
|
|
||||||
if (seedA !== seedB) return seedA - seedB
|
|
||||||
return a.row.playerId - b.row.playerId
|
|
||||||
})
|
|
||||||
|
|
||||||
let prevTotal = null
|
|
||||||
let prevRank = 0
|
|
||||||
return rows.map((entry, idx) => {
|
|
||||||
const rank = prevTotal === entry.total ? prevRank : idx + 1
|
|
||||||
prevTotal = entry.total
|
|
||||||
prevRank = rank
|
|
||||||
return { ...entry, rank }
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function isFinalist(playerId) {
|
function isFinalist(playerId) {
|
||||||
return finalistIds.value.has(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(
|
watch(
|
||||||
() =>
|
() =>
|
||||||
`${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalTieRows.length}`,
|
`${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalOverallRows.length}|${props.finalTieRows.length}`,
|
||||||
() => {
|
() => {
|
||||||
restartListRotation()
|
restartListRotation()
|
||||||
|
restartTieRotation()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopListRotation()
|
stopListRotation()
|
||||||
|
stopTieRotation()
|
||||||
})
|
})
|
||||||
|
|
||||||
function paginateRows(rows) {
|
function paginateRows(rows) {
|
||||||
@@ -449,11 +530,36 @@ function paginateRows(rows) {
|
|||||||
return rows.slice(start, start + 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() {
|
function restartListRotation() {
|
||||||
stopListRotation()
|
stopListRotation()
|
||||||
listPageIndex.value = 0
|
listPageIndex.value = 0
|
||||||
|
|
||||||
const autoRotateView = activeView.value === 'prelim_tie' || activeView.value === 'prelim_overall' || activeView.value === 'final_tie'
|
const autoRotateView = activeView.value === 'prelim_overall' || activeView.value === 'final_overall'
|
||||||
if (!autoRotateView) return
|
if (!autoRotateView) return
|
||||||
if (listPageCount.value <= 1) return
|
if (listPageCount.value <= 1) return
|
||||||
|
|
||||||
@@ -472,4 +578,62 @@ function stopListRotation() {
|
|||||||
listRotationTimer = null
|
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>
|
</script>
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<h3 class="modal-title">{{ t('actions.resetScores') }}</h3>
|
<h3 class="modal-title">{{ t('actions.resetScores') }}</h3>
|
||||||
<p class="modal-text">{{ t('messages.confirmReset') }}</p>
|
<p class="modal-text">{{ t('messages.confirmReset') }}</p>
|
||||||
<p class="modal-text subtle">{{ t('messages.resetProofPrompt') }}</p>
|
<!-- <p class="modal-text subtle">{{ t('messages.resetProofPrompt') }}</p> -->
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="btn btn-outline" @click="$emit('confirm', false)">{{ t('actions.resetOnlyScores') }}</button>
|
<button class="btn btn-outline" @click="$emit('confirm', false)">{{ t('actions.resetOnlyScores') }}</button>
|
||||||
<button class="btn btn-danger" @click="$emit('confirm', true)">{{ t('actions.resetScoresAndProofs') }}</button>
|
|
||||||
<button class="btn btn-secondary" @click="$emit('cancel')">{{ t('actions.cancel') }}</button>
|
<button class="btn btn-secondary" @click="$emit('cancel')">{{ t('actions.cancel') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<option value="prelim_overall">{{ t('labels.liveViewPrelimOverall') }}</option>
|
<option value="prelim_overall">{{ t('labels.liveViewPrelimOverall') }}</option>
|
||||||
<option value="final_groups">{{ t('labels.liveViewFinalGroups') }}</option>
|
<option value="final_groups">{{ t('labels.liveViewFinalGroups') }}</option>
|
||||||
<option value="final_tie">{{ t('labels.liveViewFinalTie') }}</option>
|
<option value="final_tie">{{ t('labels.liveViewFinalTie') }}</option>
|
||||||
|
<option value="final_overall">{{ t('labels.liveViewFinalOverall') }}</option>
|
||||||
<option value="podium">{{ t('labels.liveViewPodium') }}</option>
|
<option value="podium">{{ t('labels.liveViewPodium') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,19 +78,126 @@
|
|||||||
@change="emitPatch({ rotationPlayerCount: Number($event.target.value) || 12 })"
|
@change="emitPatch({ rotationPlayerCount: Number($event.target.value) || 12 })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
t: { type: Function, required: true },
|
t: { type: Function, required: true },
|
||||||
liveSettings: { type: Object, required: true },
|
liveSettings: { type: Object, required: true },
|
||||||
assignableGroups: { type: Array, 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'])
|
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) {
|
function emitPatch(patch) {
|
||||||
emit('update-live-setting', 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>
|
</script>
|
||||||
|
|||||||
@@ -9,24 +9,102 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap desktop-score-table">
|
<div
|
||||||
<table class="score-table">
|
v-for="section in groupedSections"
|
||||||
<thead>
|
:key="'multi-section-' + section.key"
|
||||||
<tr>
|
class="score-section-block"
|
||||||
<th>#</th>
|
:class="{ 'score-section-overflow': isOverflow(section) }"
|
||||||
<th>{{ t('table.competitor') }}</th>
|
:style="section.style"
|
||||||
<th v-if="showGroup">{{ t('table.group') }}</th>
|
>
|
||||||
<th v-if="showRank">{{ t('table.rank') }}</th>
|
<button class="score-section-toggle" type="button" @click="toggleSection(section.key)">
|
||||||
<th v-if="showSeed">{{ t('table.seed') }}</th>
|
<span class="score-section-title">{{ section.label }}</span>
|
||||||
<th v-for="round in roundStages" :key="'head-' + round.stage">{{ round.label }}</th>
|
<span class="score-section-meta">{{ section.rows.length }} • {{ isCollapsed(section.key) ? '+' : '−' }}</span>
|
||||||
<th>{{ t('table.total') }}</th>
|
</button>
|
||||||
<th>{{ t('table.verification') }}</th>
|
<div v-if="isOverflow(section)" class="score-section-alert">{{ section.label }} > {{ overflowLimit }}</div>
|
||||||
</tr>
|
|
||||||
</thead>
|
<template v-if="!isCollapsed(section.key)">
|
||||||
<tbody>
|
<div class="table-wrap desktop-score-table">
|
||||||
<tr v-for="(row, index) in filteredRows" :key="'multi-row-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)">
|
<table class="score-table">
|
||||||
<td class="mono">{{ index + 1 }}</td>
|
<thead>
|
||||||
<td>
|
<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">
|
<div class="competitor-cell compact">
|
||||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
<div>
|
<div>
|
||||||
@@ -34,29 +112,49 @@
|
|||||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div class="mono">#{{ index + 1 }}</div>
|
||||||
<td v-if="showGroup" class="mono">{{ groupCell(row) }}</td>
|
</div>
|
||||||
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
|
|
||||||
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
|
<div class="score-card-meta">
|
||||||
<td v-for="round in roundStages" :key="'cell-' + round.stage + '-' + row.playerId" class="multi-round-cell">
|
<span v-if="showGroup">{{ t('table.group') }}: {{ groupCell(row) }}</span>
|
||||||
<input
|
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
|
||||||
class="score-input score-input-compact"
|
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
|
||||||
type="number"
|
</div>
|
||||||
inputmode="numeric"
|
|
||||||
pattern="[0-9]*"
|
<div class="mobile-round-grid">
|
||||||
min="0"
|
<label
|
||||||
max="9999"
|
v-for="round in roundStages"
|
||||||
:value="scoreInputValue(round.stage, row.playerId)"
|
:key="'mobile-round-' + round.stage + '-' + row.playerId"
|
||||||
@focus="onScoreFocus(round.stage, row.playerId)"
|
class="mobile-round-item"
|
||||||
@input="onScoreInput(round.stage, row.playerId, $event)"
|
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
|
||||||
@blur="onScoreCommit(round.stage, row.playerId)"
|
>
|
||||||
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
|
<span class="score-label">{{ round.label }}</span>
|
||||||
/>
|
<input
|
||||||
</td>
|
class="score-input"
|
||||||
<td class="mono strong">{{ totalScore(row.playerId) }}</td>
|
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(round.stage, section) }"
|
||||||
<td class="verify-round-cell">
|
type="number"
|
||||||
<div class="verify-round-list">
|
inputmode="numeric"
|
||||||
<div v-for="round in roundStages" :key="'verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
|
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>
|
<span class="verify-round-label">{{ round.label }}</span>
|
||||||
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
|
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
|
||||||
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||||
@@ -70,85 +168,20 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</article>
|
||||||
<tr v-if="filteredRows.length === 0">
|
</div>
|
||||||
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td>
|
</template>
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mobile-score-cards">
|
<div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||||
<article v-for="(row, index) in filteredRows" :key="'multi-mobile-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row)">
|
|
||||||
<div class="score-card-head">
|
|
||||||
<div class="competitor-cell compact">
|
|
||||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
|
||||||
<div>
|
|
||||||
<p class="name-main">{{ displayName(row) }}</p>
|
|
||||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mono">#{{ index + 1 }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="score-card-meta">
|
|
||||||
<span v-if="showGroup">{{ t('table.group') }}: {{ groupCell(row) }}</span>
|
|
||||||
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
|
|
||||||
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mobile-round-grid">
|
|
||||||
<label v-for="round in roundStages" :key="'mobile-round-' + round.stage + '-' + row.playerId" class="mobile-round-item">
|
|
||||||
<span class="score-label">{{ round.label }}</span>
|
|
||||||
<input
|
|
||||||
class="score-input"
|
|
||||||
type="number"
|
|
||||||
inputmode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
min="0"
|
|
||||||
max="9999"
|
|
||||||
:value="scoreInputValue(round.stage, row.playerId)"
|
|
||||||
@focus="onScoreFocus(round.stage, row.playerId)"
|
|
||||||
@input="onScoreInput(round.stage, row.playerId, $event)"
|
|
||||||
@blur="onScoreCommit(round.stage, row.playerId)"
|
|
||||||
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mobile-total-line">
|
|
||||||
<span class="muted">{{ t('table.total') }}</span>
|
|
||||||
<strong class="mono">{{ totalScore(row.playerId) }}</strong>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mobile-verify-block">
|
|
||||||
<p class="mobile-verify-title">{{ t('table.verification') }}</p>
|
|
||||||
<div class="verify-round-list mobile">
|
|
||||||
<div v-for="round in roundStages" :key="'mobile-verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
|
|
||||||
<span class="verify-round-label">{{ round.label }}</span>
|
|
||||||
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
|
|
||||||
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
|
||||||
</button>
|
|
||||||
<img
|
|
||||||
v-if="hasScoreProof(round.stage, row.playerId)"
|
|
||||||
class="proof-thumb"
|
|
||||||
:src="scoreProofFor(round.stage, row.playerId)"
|
|
||||||
:alt="t('table.verification')"
|
|
||||||
@click="openProofPreview(round.stage, row.playerId)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { scoreGroupStyle } from '../../utils/scoreGroupTheme'
|
import { groupThemeStyleForKey, scoreGroupStyle } from '../../utils/scoreGroupTheme'
|
||||||
|
import { normalizedGroupCode } from '../../utils/groups'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
t: { type: Function, required: true },
|
t: { type: Function, required: true },
|
||||||
@@ -160,10 +193,14 @@ const props = defineProps({
|
|||||||
showGroup: { type: Boolean, default: false },
|
showGroup: { type: Boolean, default: false },
|
||||||
showRank: { type: Boolean, default: false },
|
showRank: { type: Boolean, default: false },
|
||||||
showSeed: { 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 },
|
playerImage: { type: Function, required: true },
|
||||||
displayName: { type: Function, required: true },
|
displayName: { type: Function, required: true },
|
||||||
secondaryName: { type: Function, required: true },
|
secondaryName: { type: Function, required: true },
|
||||||
scoreInputValue: { type: Function, required: true },
|
scoreInputValue: { type: Function, required: true },
|
||||||
|
scoreInputInvalid: { type: Function, required: true },
|
||||||
onScoreFocus: { type: Function, required: true },
|
onScoreFocus: { type: Function, required: true },
|
||||||
onScoreInput: { type: Function, required: true },
|
onScoreInput: { type: Function, required: true },
|
||||||
onScoreCommit: { type: Function, required: true },
|
onScoreCommit: { type: Function, required: true },
|
||||||
@@ -171,10 +208,14 @@ const props = defineProps({
|
|||||||
scoreProofFor: { type: Function, required: true },
|
scoreProofFor: { type: Function, required: true },
|
||||||
openScoreProofUploader: { type: Function, required: true },
|
openScoreProofUploader: { type: Function, required: true },
|
||||||
openProofPreview: { type: Function, required: true },
|
openProofPreview: { type: Function, required: true },
|
||||||
|
highlightStage: { type: String, default: '' },
|
||||||
|
highlightGroup: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['update:filter'])
|
defineEmits(['update:filter'])
|
||||||
|
|
||||||
|
const collapsedSections = ref({})
|
||||||
|
|
||||||
const filteredRows = computed(() => {
|
const filteredRows = computed(() => {
|
||||||
const query = String(props.filterText || '').trim().toLowerCase()
|
const query = String(props.filterText || '').trim().toLowerCase()
|
||||||
if (!query) return props.rows
|
if (!query) return props.rows
|
||||||
@@ -185,22 +226,144 @@ const filteredRows = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const columnCount = computed(() => {
|
const groupedSections = computed(() => {
|
||||||
let count = 4 + props.roundStages.length
|
if (!props.sectionByGroup) {
|
||||||
if (props.showGroup) count += 1
|
return [
|
||||||
if (props.showRank) count += 1
|
{
|
||||||
if (props.showSeed) count += 1
|
key: 'all',
|
||||||
return count
|
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) {
|
function groupCell(row) {
|
||||||
|
if (row?._boardGroupKey) return row._boardGroupKey
|
||||||
if (row.finalGroup === 1 || row.finalGroup === 2) {
|
if (row.finalGroup === 1 || row.finalGroup === 2) {
|
||||||
return row.finalGroup
|
return row.finalGroup
|
||||||
}
|
}
|
||||||
return row.groupCode || props.t('labels.unassigned')
|
return row.groupCode || props.t('labels.unassigned')
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreGroupStyleFor(row) {
|
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)
|
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>
|
</script>
|
||||||
|
|||||||
@@ -83,10 +83,15 @@
|
|||||||
<article v-for="group in groupedCards" :key="'group-card-' + group.code" class="group-player-card" :class="'group-' + group.key">
|
<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">
|
<header class="group-player-card-head">
|
||||||
<h3>{{ group.label }}</h3>
|
<h3>{{ group.label }}</h3>
|
||||||
<span class="pm-count-badge mono">{{ group.players.length }}</span>
|
<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>
|
</header>
|
||||||
|
|
||||||
<div class="group-player-list" v-if="group.players.length > 0">
|
<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-row" v-for="player in group.players" :key="'card-player-' + player.id">
|
||||||
<div class="group-player-top">
|
<div class="group-player-top">
|
||||||
<div class="competitor-cell">
|
<div class="competitor-cell">
|
||||||
@@ -131,14 +136,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="empty-state">{{ t('labels.noPlayers') }}</div>
|
<div v-else-if="group.players.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
t: { type: Function, required: true },
|
t: { type: Function, required: true },
|
||||||
@@ -227,6 +232,29 @@ const groupedCards = computed(() => {
|
|||||||
return cards
|
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) {
|
function comparePlayers(a, b, sort) {
|
||||||
if (sort === 'nameAr') {
|
if (sort === 'nameAr') {
|
||||||
return String(a.nameAr || '').localeCompare(String(b.nameAr || ''), 'ar') || a.id - b.id
|
return String(a.nameAr || '').localeCompare(String(b.nameAr || ''), 'ar') || a.id - b.id
|
||||||
|
|||||||
249
frontend/src/components/admin/PositionBoardEditor.vue
Normal file
249
frontend/src/components/admin/PositionBoardEditor.vue
Normal 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>
|
||||||
@@ -9,24 +9,78 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-wrap desktop-score-table">
|
<div v-for="section in groupedSections" :key="'section-' + stage + '-' + section.key" class="score-section-block" :class="{ 'tie-break': tieBreakSectionMode }" :style="section.style">
|
||||||
<table class="score-table">
|
<button class="score-section-toggle" type="button" @click="toggleSection(section.key)">
|
||||||
<thead>
|
<span class="score-section-title">{{ section.label }}</span>
|
||||||
<tr>
|
<span class="score-section-meta">{{ section.rows.length }} • {{ isCollapsed(section.key) ? '+' : '−' }}</span>
|
||||||
<th>#</th>
|
</button>
|
||||||
<th>{{ t('table.competitor') }}</th>
|
|
||||||
<th v-if="showGroup">{{ t('table.group') }}</th>
|
<template v-if="!isCollapsed(section.key)">
|
||||||
<th v-if="showScoreBeforeInput">{{ t('table.score') }}</th>
|
<div class="table-wrap desktop-score-table">
|
||||||
<th>{{ inputLabel }}</th>
|
<table class="score-table">
|
||||||
<th>{{ t('table.verification') }}</th>
|
<thead>
|
||||||
<th v-if="showRank">{{ t('table.rank') }}</th>
|
<tr>
|
||||||
<th v-if="showSeed">{{ t('table.seed') }}</th>
|
<th>#</th>
|
||||||
</tr>
|
<th>{{ t('table.competitor') }}</th>
|
||||||
</thead>
|
<th v-if="showGroup">{{ t('table.group') }}</th>
|
||||||
<tbody>
|
<th v-if="showScoreBeforeInput">{{ t('table.score') }}</th>
|
||||||
<tr v-for="(row, index) in filteredRows" :key="'desk-' + stage + '-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)">
|
<th :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">{{ inputLabel }}</th>
|
||||||
<td class="mono">{{ index + 1 }}</td>
|
<th v-if="showProofControls">{{ t('table.verification') }}</th>
|
||||||
<td>
|
<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">
|
<div class="competitor-cell compact">
|
||||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
<div>
|
<div>
|
||||||
@@ -34,125 +88,60 @@
|
|||||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<div class="mono">#{{ index + 1 }}</div>
|
||||||
<td v-if="showGroup" class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
|
|
||||||
<td v-if="showScoreBeforeInput" class="mono strong">{{ row.score }}</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
class="score-input"
|
|
||||||
type="number"
|
|
||||||
inputmode="numeric"
|
|
||||||
pattern="[0-9]*"
|
|
||||||
min="0"
|
|
||||||
max="9999"
|
|
||||||
:value="scoreInputValue(stage, row.playerId)"
|
|
||||||
@focus="onScoreFocus(stage, row.playerId)"
|
|
||||||
@input="onScoreInput(stage, row.playerId, $event)"
|
|
||||||
@blur="onScoreCommit(stage, row.playerId)"
|
|
||||||
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="proof-actions">
|
|
||||||
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
|
|
||||||
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
|
||||||
</button>
|
|
||||||
<!-- <button
|
|
||||||
v-if="hasScoreProof(stage, row.playerId)"
|
|
||||||
class="btn btn-ai btn-xs"
|
|
||||||
@click="requestScoreAdvice(stage, row.playerId)"
|
|
||||||
>
|
|
||||||
{{ t('actions.aiAdvisor') }}
|
|
||||||
</button> -->
|
|
||||||
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
|
|
||||||
{{ t('actions.removeProof') }}
|
|
||||||
</button>
|
|
||||||
<img
|
|
||||||
v-if="hasScoreProof(stage, row.playerId)"
|
|
||||||
class="proof-thumb"
|
|
||||||
:src="scoreProofFor(stage, row.playerId)"
|
|
||||||
:alt="t('table.verification')"
|
|
||||||
@click="openProofPreview(stage, row.playerId)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
|
|
||||||
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="filteredRows.length === 0">
|
|
||||||
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mobile-score-cards">
|
|
||||||
<article v-for="(row, index) in filteredRows" :key="'mob-' + stage + '-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row)">
|
|
||||||
<div class="score-card-head">
|
|
||||||
<div class="competitor-cell compact">
|
|
||||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
|
||||||
<div>
|
|
||||||
<p class="name-main">{{ displayName(row) }}</p>
|
|
||||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="mono">#{{ index + 1 }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="score-card-meta">
|
<div class="score-card-meta">
|
||||||
<span v-if="showGroup">{{ t('table.group') }}: {{ row.groupCode || t('labels.unassigned') }}</span>
|
<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="showScoreBeforeInput">{{ t('table.score') }}: {{ row.score }}</span>
|
||||||
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
|
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
|
||||||
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
|
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="score-label">{{ inputLabel }}</label>
|
<label class="score-label" :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">{{ inputLabel }}</label>
|
||||||
<input
|
<input
|
||||||
class="score-input"
|
class="score-input"
|
||||||
type="number"
|
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(stage, section) }"
|
||||||
inputmode="numeric"
|
type="number"
|
||||||
pattern="[0-9]*"
|
inputmode="numeric"
|
||||||
min="0"
|
pattern="[0-9]*"
|
||||||
max="9999"
|
min="0"
|
||||||
:value="scoreInputValue(stage, row.playerId)"
|
max="100"
|
||||||
@focus="onScoreFocus(stage, row.playerId)"
|
:value="scoreInputValue(stage, row.playerId)"
|
||||||
@input="onScoreInput(stage, row.playerId, $event)"
|
@focus="onScoreFocus(stage, row.playerId)"
|
||||||
@blur="onScoreCommit(stage, row.playerId)"
|
@input="onScoreInput(stage, row.playerId, $event)"
|
||||||
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
|
@blur="onScoreCommit(stage, row.playerId)"
|
||||||
/>
|
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="proof-actions mobile-proof-actions">
|
<div v-if="showProofControls" class="proof-actions mobile-proof-actions">
|
||||||
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
|
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
|
||||||
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||||
</button>
|
</button>
|
||||||
<!-- <button
|
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
|
||||||
v-if="hasScoreProof(stage, row.playerId)"
|
{{ t('actions.removeProof') }}
|
||||||
class="btn btn-ai btn-xs"
|
</button>
|
||||||
@click="requestScoreAdvice(stage, row.playerId)"
|
<img
|
||||||
>
|
v-if="hasScoreProof(stage, row.playerId)"
|
||||||
{{ t('actions.aiAdvisor') }}
|
class="proof-thumb"
|
||||||
</button> -->
|
:src="scoreProofFor(stage, row.playerId)"
|
||||||
|
:alt="t('table.verification')"
|
||||||
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
|
@click="openProofPreview(stage, row.playerId)"
|
||||||
{{ t('actions.removeProof') }}
|
/>
|
||||||
</button>
|
</div>
|
||||||
<img
|
</article>
|
||||||
v-if="hasScoreProof(stage, row.playerId)"
|
|
||||||
class="proof-thumb"
|
|
||||||
:src="scoreProofFor(stage, row.playerId)"
|
|
||||||
:alt="t('table.verification')"
|
|
||||||
@click="openProofPreview(stage, row.playerId)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</template>
|
||||||
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { scoreGroupStyle, scoreValueStyle } from '../../utils/scoreGroupTheme'
|
import { groupThemeStyleForKey, scoreGroupStyle, scoreValueStyle } from '../../utils/scoreGroupTheme'
|
||||||
|
import { normalizedGroupCode } from '../../utils/groups'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
t: { type: Function, required: true },
|
t: { type: Function, required: true },
|
||||||
@@ -166,10 +155,14 @@ const props = defineProps({
|
|||||||
colorMode: { type: String, default: 'group' },
|
colorMode: { type: String, default: 'group' },
|
||||||
showScoreBeforeInput: { type: Boolean, default: false },
|
showScoreBeforeInput: { type: Boolean, default: false },
|
||||||
inputLabel: { type: String, required: true },
|
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 },
|
playerImage: { type: Function, required: true },
|
||||||
displayName: { type: Function, required: true },
|
displayName: { type: Function, required: true },
|
||||||
secondaryName: { type: Function, required: true },
|
secondaryName: { type: Function, required: true },
|
||||||
scoreInputValue: { type: Function, required: true },
|
scoreInputValue: { type: Function, required: true },
|
||||||
|
scoreInputInvalid: { type: Function, required: true },
|
||||||
onScoreFocus: { type: Function, required: true },
|
onScoreFocus: { type: Function, required: true },
|
||||||
onScoreInput: { type: Function, required: true },
|
onScoreInput: { type: Function, required: true },
|
||||||
onScoreCommit: { type: Function, required: true },
|
onScoreCommit: { type: Function, required: true },
|
||||||
@@ -179,10 +172,14 @@ const props = defineProps({
|
|||||||
removeScoreProof: { type: Function, required: true },
|
removeScoreProof: { type: Function, required: true },
|
||||||
openProofPreview: { type: Function, required: true },
|
openProofPreview: { type: Function, required: true },
|
||||||
requestScoreAdvice: { type: Function, required: true },
|
requestScoreAdvice: { type: Function, required: true },
|
||||||
|
highlightStage: { type: String, default: '' },
|
||||||
|
highlightGroup: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['update:filter'])
|
defineEmits(['update:filter'])
|
||||||
|
|
||||||
|
const collapsedSections = ref({})
|
||||||
|
|
||||||
const filteredRows = computed(() => {
|
const filteredRows = computed(() => {
|
||||||
const query = String(props.filterText || '').trim().toLowerCase()
|
const query = String(props.filterText || '').trim().toLowerCase()
|
||||||
if (!query) return props.rows
|
if (!query) return props.rows
|
||||||
@@ -193,19 +190,151 @@ const filteredRows = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const columnCount = computed(() => {
|
const groupedSections = computed(() => {
|
||||||
let count = 4
|
if (!props.sectionByGroup) {
|
||||||
if (props.showGroup) count += 1
|
return [
|
||||||
if (props.showScoreBeforeInput) count += 1
|
{
|
||||||
if (props.showRank) count += 1
|
key: 'all',
|
||||||
if (props.showSeed) count += 1
|
label: props.t('labels.allPlayers'),
|
||||||
return count
|
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]),
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
function scoreGroupStyleFor(row) {
|
watch(
|
||||||
if (props.colorMode === 'score') {
|
groupedSections,
|
||||||
return scoreValueStyle(row?.score)
|
(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)
|
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>
|
</script>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const I18N = {
|
|||||||
liveTracker: 'LIVE TRACKER',
|
liveTracker: 'LIVE TRACKER',
|
||||||
mode: 'الوضع',
|
mode: 'الوضع',
|
||||||
language: 'اللغة',
|
language: 'اللغة',
|
||||||
|
fullscreen: 'ملء الشاشة',
|
||||||
lastSync: 'آخر مزامنة',
|
lastSync: 'آخر مزامنة',
|
||||||
loading: 'جاري تحميل بيانات البطولة...',
|
loading: 'جاري تحميل بيانات البطولة...',
|
||||||
players: 'لاعب',
|
players: 'لاعب',
|
||||||
@@ -23,11 +24,14 @@ export const I18N = {
|
|||||||
allPlayers: 'جميع اللاعبين',
|
allPlayers: 'جميع اللاعبين',
|
||||||
top12: 'أفضل 12 متأهل',
|
top12: 'أفضل 12 متأهل',
|
||||||
finalist: 'متأهل',
|
finalist: 'متأهل',
|
||||||
|
tieBreak: 'كسر تعادل',
|
||||||
notFinalist: 'غير متأهل',
|
notFinalist: 'غير متأهل',
|
||||||
noPlayers: 'لا يوجد لاعبين بعد',
|
noPlayers: 'لا يوجد لاعبين بعد',
|
||||||
noFinalists: 'لا يوجد متأهلون بعد',
|
noFinalists: 'لا يوجد متأهلون بعد',
|
||||||
finalGroup1: 'المجموعة النهائية 1 (المراكز 1-6)',
|
finalGroup1: 'المجموعة النهائية 1 (المراكز 1-6)',
|
||||||
finalGroup2: 'المجموعة النهائية 2 (المراكز 7-12)',
|
finalGroup2: 'المجموعة النهائية 2 (المراكز 7-12)',
|
||||||
|
finalGroupSeeds1: 'المجموعة النهائية (المراكز 1-6)',
|
||||||
|
finalGroupSeeds2: 'المجموعة النهائية (المراكز 7-12)',
|
||||||
tieSlots: 'عدد المقاعد المتاحة من كسر التعادل',
|
tieSlots: 'عدد المقاعد المتاحة من كسر التعادل',
|
||||||
waiting: 'بانتظار النتيجة',
|
waiting: 'بانتظار النتيجة',
|
||||||
viewProofInView: 'إظهار صور إثبات النتيجة في وضع العرض',
|
viewProofInView: 'إظهار صور إثبات النتيجة في وضع العرض',
|
||||||
@@ -40,6 +44,7 @@ export const I18N = {
|
|||||||
liveViewPrelimOverall: 'الترتيب العام للتمهيدي',
|
liveViewPrelimOverall: 'الترتيب العام للتمهيدي',
|
||||||
liveViewFinalGroups: 'شاشة مجموعات النهائي',
|
liveViewFinalGroups: 'شاشة مجموعات النهائي',
|
||||||
liveViewFinalTie: 'كسر تعادل النهائي',
|
liveViewFinalTie: 'كسر تعادل النهائي',
|
||||||
|
liveViewFinalOverall: 'الترتيب العام للنهائي',
|
||||||
liveViewPodium: 'منصة التتويج',
|
liveViewPodium: 'منصة التتويج',
|
||||||
showGroupLive: 'إظهار شاشة المجموعات الحية',
|
showGroupLive: 'إظهار شاشة المجموعات الحية',
|
||||||
showPrelimTie: 'إظهار كسر تعادل التمهيدي',
|
showPrelimTie: 'إظهار كسر تعادل التمهيدي',
|
||||||
@@ -56,6 +61,21 @@ export const I18N = {
|
|||||||
currentScore: 'النتيجة الحالية',
|
currentScore: 'النتيجة الحالية',
|
||||||
aiSuggestedScore: 'النتيجة المقترحة',
|
aiSuggestedScore: 'النتيجة المقترحة',
|
||||||
confidence: 'مستوى الثقة',
|
confidence: 'مستوى الثقة',
|
||||||
|
stageControl: 'المرحلة الحالية',
|
||||||
|
stagePhase: 'الطور',
|
||||||
|
stageGroup: 'المجموعة',
|
||||||
|
stageRound: 'الجولة',
|
||||||
|
currentStage: 'المرحلة الجارية',
|
||||||
|
lastStage: 'آخر مرحلة',
|
||||||
|
startedAt: 'بدأت',
|
||||||
|
endedAt: 'انتهت',
|
||||||
|
noCurrentStage: 'لا توجد مرحلة جارية',
|
||||||
|
noLastStage: 'لا توجد مرحلة سابقة',
|
||||||
|
phasePreliminary: 'التمهيدي',
|
||||||
|
phasePrelimTie: 'كسر تعادل التمهيدي',
|
||||||
|
phaseFinal: 'النهائي',
|
||||||
|
phaseFinalTie: 'كسر تعادل النهائي',
|
||||||
|
groupAssignment: 'توزيع المجموعات',
|
||||||
},
|
},
|
||||||
sections: {
|
sections: {
|
||||||
groupsTitle: 'عرض اللاعبين والمجموعات',
|
groupsTitle: 'عرض اللاعبين والمجموعات',
|
||||||
@@ -92,6 +112,7 @@ export const I18N = {
|
|||||||
competitor: 'اللاعب',
|
competitor: 'اللاعب',
|
||||||
group: 'المجموعة',
|
group: 'المجموعة',
|
||||||
rank: 'الترتيب',
|
rank: 'الترتيب',
|
||||||
|
position: 'الموضع',
|
||||||
score: 'النتيجة',
|
score: 'النتيجة',
|
||||||
total: 'المجموع',
|
total: 'المجموع',
|
||||||
round1: 'جولة 1',
|
round1: 'جولة 1',
|
||||||
@@ -120,6 +141,7 @@ export const I18N = {
|
|||||||
removeImage: 'حذف الصورة',
|
removeImage: 'حذف الصورة',
|
||||||
delete: 'حذف',
|
delete: 'حذف',
|
||||||
resetScores: 'تصفير نتائج المرحلة',
|
resetScores: 'تصفير نتائج المرحلة',
|
||||||
|
resetFinalGroupBySeed: 'إعادة مجموعات النهائي حسب التصنيف (1-6 / 7-12)',
|
||||||
resetOnlyScores: 'تصفير النتائج فقط',
|
resetOnlyScores: 'تصفير النتائج فقط',
|
||||||
resetScoresAndProofs: 'تصفير النتائج وحذف الإثبات',
|
resetScoresAndProofs: 'تصفير النتائج وحذف الإثبات',
|
||||||
randomEvenGroups: 'توزيع عشوائي متوازن',
|
randomEvenGroups: 'توزيع عشوائي متوازن',
|
||||||
@@ -139,6 +161,11 @@ export const I18N = {
|
|||||||
resetPosition: 'إعادة الضبط',
|
resetPosition: 'إعادة الضبط',
|
||||||
liveModeRotate: 'تدوير تلقائي',
|
liveModeRotate: 'تدوير تلقائي',
|
||||||
liveModeFixed: 'ثابت',
|
liveModeFixed: 'ثابت',
|
||||||
|
enterFullscreen: 'دخول ملء الشاشة',
|
||||||
|
exitFullscreen: 'الخروج من ملء الشاشة',
|
||||||
|
startStage: 'بدء المرحلة',
|
||||||
|
endStage: 'إنهاء المرحلة',
|
||||||
|
goToStageScoring: 'الانتقال إلى إدخال نتائج المرحلة',
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
username: 'اسم المستخدم',
|
username: 'اسم المستخدم',
|
||||||
@@ -166,13 +193,14 @@ export const I18N = {
|
|||||||
confirmDelete: 'هل تريد حذف اللاعب؟',
|
confirmDelete: 'هل تريد حذف اللاعب؟',
|
||||||
confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟',
|
confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟',
|
||||||
resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟',
|
resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟',
|
||||||
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 9999.',
|
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 100.',
|
||||||
unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.',
|
unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.',
|
||||||
errorPrefix: 'حدث خطأ',
|
errorPrefix: 'حدث خطأ',
|
||||||
noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.',
|
noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.',
|
||||||
noNameToConvert: 'لا يوجد اسم للتحويل.',
|
noNameToConvert: 'لا يوجد اسم للتحويل.',
|
||||||
aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...',
|
aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...',
|
||||||
noProofForAi: 'لا توجد صورة إثبات لتحليلها.',
|
noProofForAi: 'لا توجد صورة إثبات لتحليلها.',
|
||||||
|
noGroupForPhase: 'لا توجد مجموعات متاحة لهذا الطور.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
@@ -190,6 +218,7 @@ export const I18N = {
|
|||||||
liveTracker: 'LIVE TRACKER',
|
liveTracker: 'LIVE TRACKER',
|
||||||
mode: 'Mode',
|
mode: 'Mode',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
|
fullscreen: 'Fullscreen',
|
||||||
lastSync: 'Last sync',
|
lastSync: 'Last sync',
|
||||||
loading: 'Loading tournament data...',
|
loading: 'Loading tournament data...',
|
||||||
players: 'Players',
|
players: 'Players',
|
||||||
@@ -199,11 +228,14 @@ export const I18N = {
|
|||||||
allPlayers: 'All players',
|
allPlayers: 'All players',
|
||||||
top12: 'Top 12 finalists',
|
top12: 'Top 12 finalists',
|
||||||
finalist: 'Finalist',
|
finalist: 'Finalist',
|
||||||
|
tieBreak: 'Tie-break',
|
||||||
notFinalist: 'Not finalist',
|
notFinalist: 'Not finalist',
|
||||||
noPlayers: 'No players yet',
|
noPlayers: 'No players yet',
|
||||||
noFinalists: 'No finalists yet',
|
noFinalists: 'No finalists yet',
|
||||||
finalGroup1: 'Final Group 1 (Seeds 1-6)',
|
finalGroup1: 'Final Group 1 (Seeds 1-6)',
|
||||||
finalGroup2: 'Final Group 2 (Seeds 7-12)',
|
finalGroup2: 'Final Group 2 (Seeds 7-12)',
|
||||||
|
finalGroupSeeds1: 'Final Group Seeds 1-6',
|
||||||
|
finalGroupSeeds2: 'Final Group Seeds 7-12',
|
||||||
tieSlots: 'Tie-break slots',
|
tieSlots: 'Tie-break slots',
|
||||||
waiting: 'Waiting',
|
waiting: 'Waiting',
|
||||||
viewProofInView: 'Allow proof images in view mode',
|
viewProofInView: 'Allow proof images in view mode',
|
||||||
@@ -216,6 +248,7 @@ export const I18N = {
|
|||||||
liveViewPrelimOverall: 'Preliminary overall',
|
liveViewPrelimOverall: 'Preliminary overall',
|
||||||
liveViewFinalGroups: 'Final groups board',
|
liveViewFinalGroups: 'Final groups board',
|
||||||
liveViewFinalTie: 'Final tie-break',
|
liveViewFinalTie: 'Final tie-break',
|
||||||
|
liveViewFinalOverall: 'Final overall ranking',
|
||||||
liveViewPodium: 'Podium',
|
liveViewPodium: 'Podium',
|
||||||
showGroupLive: 'Show live group board',
|
showGroupLive: 'Show live group board',
|
||||||
showPrelimTie: 'Show preliminary tie-break',
|
showPrelimTie: 'Show preliminary tie-break',
|
||||||
@@ -232,6 +265,21 @@ export const I18N = {
|
|||||||
currentScore: 'Current score',
|
currentScore: 'Current score',
|
||||||
aiSuggestedScore: 'AI suggested score',
|
aiSuggestedScore: 'AI suggested score',
|
||||||
confidence: 'Confidence',
|
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: {
|
sections: {
|
||||||
groupsTitle: 'Players & Groups Overview',
|
groupsTitle: 'Players & Groups Overview',
|
||||||
@@ -268,6 +316,7 @@ export const I18N = {
|
|||||||
competitor: 'Player',
|
competitor: 'Player',
|
||||||
group: 'Group',
|
group: 'Group',
|
||||||
rank: 'Rank',
|
rank: 'Rank',
|
||||||
|
position: 'Position',
|
||||||
score: 'Score',
|
score: 'Score',
|
||||||
total: 'Total',
|
total: 'Total',
|
||||||
round1: 'Round 1',
|
round1: 'Round 1',
|
||||||
@@ -296,6 +345,7 @@ export const I18N = {
|
|||||||
removeImage: 'Remove Image',
|
removeImage: 'Remove Image',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
resetScores: 'Reset Stage Scores',
|
resetScores: 'Reset Stage Scores',
|
||||||
|
resetFinalGroupBySeed: 'Reset Final Groups By Seed (1-6 / 7-12)',
|
||||||
resetOnlyScores: 'Reset scores only',
|
resetOnlyScores: 'Reset scores only',
|
||||||
resetScoresAndProofs: 'Reset scores and proofs',
|
resetScoresAndProofs: 'Reset scores and proofs',
|
||||||
randomEvenGroups: 'Random even grouping',
|
randomEvenGroups: 'Random even grouping',
|
||||||
@@ -315,6 +365,11 @@ export const I18N = {
|
|||||||
resetPosition: 'Reset position',
|
resetPosition: 'Reset position',
|
||||||
liveModeRotate: 'Auto rotate',
|
liveModeRotate: 'Auto rotate',
|
||||||
liveModeFixed: 'Fixed',
|
liveModeFixed: 'Fixed',
|
||||||
|
enterFullscreen: 'Enter Fullscreen',
|
||||||
|
exitFullscreen: 'Exit Fullscreen',
|
||||||
|
startStage: 'Start Stage',
|
||||||
|
endStage: 'End Stage',
|
||||||
|
goToStageScoring: 'Go To Stage Scoring',
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
@@ -342,13 +397,14 @@ export const I18N = {
|
|||||||
confirmDelete: 'Delete this player?',
|
confirmDelete: 'Delete this player?',
|
||||||
confirmReset: 'Reset this stage scores?',
|
confirmReset: 'Reset this stage scores?',
|
||||||
resetProofPrompt: 'Also remove all proof images for this stage?',
|
resetProofPrompt: 'Also remove all proof images for this stage?',
|
||||||
invalidScore: 'Score must be between 0 and 9999.',
|
invalidScore: 'Score must be between 0 and 100.',
|
||||||
unauthorized: 'Admin session expired. Please login again.',
|
unauthorized: 'Admin session expired. Please login again.',
|
||||||
errorPrefix: 'Error',
|
errorPrefix: 'Error',
|
||||||
noGroupsConfigured: 'No primary groups configured.',
|
noGroupsConfigured: 'No primary groups configured.',
|
||||||
noNameToConvert: 'No name available to convert.',
|
noNameToConvert: 'No name available to convert.',
|
||||||
aiAnalyzing: 'Analyzing image with AI...',
|
aiAnalyzing: 'Analyzing image with AI...',
|
||||||
noProofForAi: 'No proof image available to analyze.',
|
noProofForAi: 'No proof image available to analyze.',
|
||||||
|
noGroupForPhase: 'No groups available for this phase.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -392,6 +448,8 @@ export function createInitialState() {
|
|||||||
finalRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
|
finalRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
|
||||||
podium: [],
|
podium: [],
|
||||||
},
|
},
|
||||||
|
current_stage: null,
|
||||||
|
last_stage: null,
|
||||||
serverTime: '',
|
serverTime: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,24 @@
|
|||||||
import { normalizedGroupCode } from './groups'
|
import { normalizedGroupCode } from './groups'
|
||||||
|
|
||||||
const PRESET = {
|
const PRESET = {
|
||||||
A: { accent: '#2f4d9a', soft: 'rgba(47, 77, 154, 0.10)', border: 'rgba(47, 77, 154, 0.34)' },
|
A: { accent: '#0d1931', soft: 'rgba(13, 25, 49, 0.14)', border: 'rgba(13, 25, 49, 0.44)' },
|
||||||
B: { accent: '#0d8fa5', soft: 'rgba(13, 143, 165, 0.10)', border: 'rgba(13, 143, 165, 0.34)' },
|
B: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' },
|
||||||
C: { accent: '#d54a3f', soft: 'rgba(213, 74, 63, 0.10)', border: 'rgba(213, 74, 63, 0.34)' },
|
C: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' },
|
||||||
D: { accent: '#c2891f', soft: 'rgba(194, 137, 31, 0.12)', border: 'rgba(194, 137, 31, 0.36)' },
|
D: { accent: '#1b325f', soft: 'rgba(27, 50, 95, 0.14)', border: 'rgba(27, 50, 95, 0.42)' },
|
||||||
F1: { accent: '#5b4fc9', soft: 'rgba(91, 79, 201, 0.11)', border: 'rgba(91, 79, 201, 0.36)' },
|
F1: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' },
|
||||||
F2: { accent: '#0f9f63', soft: 'rgba(15, 159, 99, 0.11)', border: 'rgba(15, 159, 99, 0.36)' },
|
F2: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' },
|
||||||
U: { accent: '#7f8ca8', soft: 'rgba(127, 140, 168, 0.11)', border: 'rgba(127, 140, 168, 0.34)' },
|
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) {
|
export function scoreGroupToken(row) {
|
||||||
const finalGroup = Number(row?.finalGroup || 0)
|
const finalGroup = Number(row?.finalGroup || 0)
|
||||||
if (finalGroup === 1) return 'F1'
|
if (finalGroup === 1) return 'F1'
|
||||||
@@ -30,12 +39,7 @@ export function scoreGroupStyle(row) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hue = hashToHue(token)
|
return groupThemeStyleForKey(token)
|
||||||
return {
|
|
||||||
'--score-group-accent': `hsl(${hue} 70% 38%)`,
|
|
||||||
'--score-group-soft': `hsla(${hue}, 72%, 46%, 0.10)`,
|
|
||||||
'--score-group-border': `hsla(${hue}, 70%, 38%, 0.34)`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scoreValueStyle(scoreValue) {
|
export function scoreValueStyle(scoreValue) {
|
||||||
@@ -49,6 +53,26 @@ export function scoreValueStyle(scoreValue) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function hashToHue(value) {
|
||||||
const input = String(value || 'U')
|
const input = String(value || 'U')
|
||||||
let hash = 0
|
let hash = 0
|
||||||
|
|||||||
137
getdata.json
Normal file
137
getdata.json
Normal 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
5
phoenix/.formatter.exs
Normal 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
31
phoenix/.gitignore
vendored
Normal 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
111
phoenix/AGENTS.md
Normal 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
51
phoenix/README.md
Normal 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
35
phoenix/config/config.exs
Normal 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
60
phoenix/config/dev.exs
Normal 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
19
phoenix/config/prod.exs
Normal 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.
|
||||||
92
phoenix/config/runtime.exs
Normal file
92
phoenix/config/runtime.exs
Normal 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
28
phoenix/config/test.exs
Normal 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
|
||||||
9
phoenix/lib/shooting_event_phx.ex
Normal file
9
phoenix/lib/shooting_event_phx.ex
Normal 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
|
||||||
199
phoenix/lib/shooting_event_phx/ai/gemini.ex
Normal file
199
phoenix/lib/shooting_event_phx/ai/gemini.ex
Normal 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
|
||||||
43
phoenix/lib/shooting_event_phx/application.ex
Normal file
43
phoenix/lib/shooting_event_phx/application.ex
Normal 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
|
||||||
51
phoenix/lib/shooting_event_phx/auth.ex
Normal file
51
phoenix/lib/shooting_event_phx/auth.ex
Normal 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
|
||||||
46
phoenix/lib/shooting_event_phx/auth/session_store.ex
Normal file
46
phoenix/lib/shooting_event_phx/auth/session_store.ex
Normal 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
|
||||||
11
phoenix/lib/shooting_event_phx/domain/app_setting.ex
Normal file
11
phoenix/lib/shooting_event_phx/domain/app_setting.ex
Normal 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
|
||||||
272
phoenix/lib/shooting_event_phx/domain/derived.ex
Normal file
272
phoenix/lib/shooting_event_phx/domain/derived.ex
Normal 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
|
||||||
353
phoenix/lib/shooting_event_phx/domain/event_data.ex
Normal file
353
phoenix/lib/shooting_event_phx/domain/event_data.ex
Normal 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
|
||||||
14
phoenix/lib/shooting_event_phx/domain/player.ex
Normal file
14
phoenix/lib/shooting_event_phx/domain/player.ex
Normal 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
|
||||||
13
phoenix/lib/shooting_event_phx/domain/score.ex
Normal file
13
phoenix/lib/shooting_event_phx/domain/score.ex
Normal 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
|
||||||
13
phoenix/lib/shooting_event_phx/domain/score_attachment.ex
Normal file
13
phoenix/lib/shooting_event_phx/domain/score_attachment.ex
Normal 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
|
||||||
297
phoenix/lib/shooting_event_phx/domain/settings.ex
Normal file
297
phoenix/lib/shooting_event_phx/domain/settings.ex
Normal 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
|
||||||
42
phoenix/lib/shooting_event_phx/domain/stages.ex
Normal file
42
phoenix/lib/shooting_event_phx/domain/stages.ex
Normal 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
|
||||||
43
phoenix/lib/shooting_event_phx/domain/state_service.ex
Normal file
43
phoenix/lib/shooting_event_phx/domain/state_service.ex
Normal 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
|
||||||
15
phoenix/lib/shooting_event_phx/events.ex
Normal file
15
phoenix/lib/shooting_event_phx/events.ex
Normal 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
|
||||||
5
phoenix/lib/shooting_event_phx/repo.ex
Normal file
5
phoenix/lib/shooting_event_phx/repo.ex
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
defmodule ShootingEventPhx.Repo do
|
||||||
|
use Ecto.Repo,
|
||||||
|
otp_app: :shooting_event_phx,
|
||||||
|
adapter: Ecto.Adapters.SQLite3
|
||||||
|
end
|
||||||
63
phoenix/lib/shooting_event_phx_web.ex
Normal file
63
phoenix/lib/shooting_event_phx_web.ex
Normal 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
|
||||||
221
phoenix/lib/shooting_event_phx_web/controllers/api_controller.ex
Normal file
221
phoenix/lib/shooting_event_phx_web/controllers/api_controller.ex
Normal 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
|
||||||
21
phoenix/lib/shooting_event_phx_web/controllers/error_json.ex
Normal file
21
phoenix/lib/shooting_event_phx_web/controllers/error_json.ex
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
50
phoenix/lib/shooting_event_phx_web/endpoint.ex
Normal file
50
phoenix/lib/shooting_event_phx_web/endpoint.ex
Normal 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
|
||||||
18
phoenix/lib/shooting_event_phx_web/plugs/admin_only.ex
Normal file
18
phoenix/lib/shooting_event_phx_web/plugs/admin_only.ex
Normal 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
|
||||||
20
phoenix/lib/shooting_event_phx_web/plugs/cors.ex
Normal file
20
phoenix/lib/shooting_event_phx_web/plugs/cors.ex
Normal 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
|
||||||
49
phoenix/lib/shooting_event_phx_web/router.ex
Normal file
49
phoenix/lib/shooting_event_phx_web/router.ex
Normal 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
|
||||||
93
phoenix/lib/shooting_event_phx_web/telemetry.ex
Normal file
93
phoenix/lib/shooting_event_phx_web/telemetry.ex
Normal 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
70
phoenix/mix.exs
Normal 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
32
phoenix/mix.lock
Normal 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"},
|
||||||
|
}
|
||||||
4
phoenix/priv/repo/migrations/.formatter.exs
Normal file
4
phoenix/priv/repo/migrations/.formatter.exs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
import_deps: [:ecto_sql],
|
||||||
|
inputs: ["*.exs"]
|
||||||
|
]
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
defmodule ShootingEventPhx.Repo.Migrations.InitEventSchema do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
execute("""
|
||||||
|
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
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
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
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
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
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
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');
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
INSERT OR IGNORE INTO scores(stage, player_id, score)
|
||||||
|
SELECT 'prelim_r1', player_id, score FROM scores WHERE stage = 'preliminary';
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
INSERT OR IGNORE INTO scores(stage, player_id, score)
|
||||||
|
SELECT 'final_r1', player_id, score FROM scores WHERE stage = 'final';
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
|
||||||
|
SELECT 'prelim_r1', player_id, image_data FROM score_attachments WHERE stage = 'preliminary';
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
|
||||||
|
SELECT 'final_r1', player_id, image_data FROM score_attachments WHERE stage = 'final';
|
||||||
|
""")
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
execute("DROP TABLE IF EXISTS score_attachments;")
|
||||||
|
execute("DROP TABLE IF EXISTS scores;")
|
||||||
|
execute("DROP TABLE IF EXISTS app_settings;")
|
||||||
|
execute("DROP TABLE IF EXISTS players;")
|
||||||
|
end
|
||||||
|
end
|
||||||
11
phoenix/priv/repo/seeds.exs
Normal file
11
phoenix/priv/repo/seeds.exs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Script for populating the database. You can run it as:
|
||||||
|
#
|
||||||
|
# mix run priv/repo/seeds.exs
|
||||||
|
#
|
||||||
|
# Inside the script, you can read and write to any of your
|
||||||
|
# repositories directly:
|
||||||
|
#
|
||||||
|
# ShootingEventPhx.Repo.insert!(%ShootingEventPhx.SomeSchema{})
|
||||||
|
#
|
||||||
|
# We recommend using the bang functions (`insert!`, `update!`
|
||||||
|
# and so on) as they will fail if something goes wrong.
|
||||||
BIN
phoenix/priv/static/favicon.ico
Normal file
BIN
phoenix/priv/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 B |
5
phoenix/priv/static/robots.txt
Normal file
5
phoenix/priv/static/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
|
||||||
|
#
|
||||||
|
# To ban all spiders from the entire site uncomment the next two lines:
|
||||||
|
# User-agent: *
|
||||||
|
# Disallow: /
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
defmodule ShootingEventPhxWeb.ErrorJSONTest do
|
||||||
|
use ShootingEventPhxWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
test "renders 404" do
|
||||||
|
assert ShootingEventPhxWeb.ErrorJSON.render("404.json", %{}) == %{
|
||||||
|
errors: %{detail: "Not Found"}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders 500" do
|
||||||
|
assert ShootingEventPhxWeb.ErrorJSON.render("500.json", %{}) ==
|
||||||
|
%{errors: %{detail: "Internal Server Error"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
38
phoenix/test/support/conn_case.ex
Normal file
38
phoenix/test/support/conn_case.ex
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
defmodule ShootingEventPhxWeb.ConnCase do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines the test case to be used by
|
||||||
|
tests that require setting up a connection.
|
||||||
|
|
||||||
|
Such tests rely on `Phoenix.ConnTest` and also
|
||||||
|
import other functionality to make it easier
|
||||||
|
to build common data structures and query the data layer.
|
||||||
|
|
||||||
|
Finally, if the test case interacts with the database,
|
||||||
|
we enable the SQL sandbox, so changes done to the database
|
||||||
|
are reverted at the end of every test. If you are using
|
||||||
|
PostgreSQL, you can even run database tests asynchronously
|
||||||
|
by setting `use ShootingEventPhxWeb.ConnCase, async: true`, although
|
||||||
|
this option is not recommended for other databases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.CaseTemplate
|
||||||
|
|
||||||
|
using do
|
||||||
|
quote do
|
||||||
|
# The default endpoint for testing
|
||||||
|
@endpoint ShootingEventPhxWeb.Endpoint
|
||||||
|
|
||||||
|
use ShootingEventPhxWeb, :verified_routes
|
||||||
|
|
||||||
|
# Import conveniences for testing with connections
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.ConnTest
|
||||||
|
import ShootingEventPhxWeb.ConnCase
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
setup tags do
|
||||||
|
ShootingEventPhx.DataCase.setup_sandbox(tags)
|
||||||
|
{:ok, conn: Phoenix.ConnTest.build_conn()}
|
||||||
|
end
|
||||||
|
end
|
||||||
58
phoenix/test/support/data_case.ex
Normal file
58
phoenix/test/support/data_case.ex
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
defmodule ShootingEventPhx.DataCase do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines the setup for tests requiring
|
||||||
|
access to the application's data layer.
|
||||||
|
|
||||||
|
You may define functions here to be used as helpers in
|
||||||
|
your tests.
|
||||||
|
|
||||||
|
Finally, if the test case interacts with the database,
|
||||||
|
we enable the SQL sandbox, so changes done to the database
|
||||||
|
are reverted at the end of every test. If you are using
|
||||||
|
PostgreSQL, you can even run database tests asynchronously
|
||||||
|
by setting `use ShootingEventPhx.DataCase, async: true`, although
|
||||||
|
this option is not recommended for other databases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.CaseTemplate
|
||||||
|
|
||||||
|
using do
|
||||||
|
quote do
|
||||||
|
alias ShootingEventPhx.Repo
|
||||||
|
|
||||||
|
import Ecto
|
||||||
|
import Ecto.Changeset
|
||||||
|
import Ecto.Query
|
||||||
|
import ShootingEventPhx.DataCase
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
setup tags do
|
||||||
|
ShootingEventPhx.DataCase.setup_sandbox(tags)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sets up the sandbox based on the test tags.
|
||||||
|
"""
|
||||||
|
def setup_sandbox(tags) do
|
||||||
|
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(ShootingEventPhx.Repo, shared: not tags[:async])
|
||||||
|
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
A helper that transforms changeset errors into a map of messages.
|
||||||
|
|
||||||
|
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
|
||||||
|
assert "password is too short" in errors_on(changeset).password
|
||||||
|
assert %{password: ["password is too short"]} = errors_on(changeset)
|
||||||
|
|
||||||
|
"""
|
||||||
|
def errors_on(changeset) do
|
||||||
|
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
|
||||||
|
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
|
||||||
|
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
2
phoenix/test/test_helper.exs
Normal file
2
phoenix/test/test_helper.exs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ExUnit.start()
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(ShootingEventPhx.Repo, :manual)
|
||||||
Reference in New Issue
Block a user