Compare commits
9 Commits
master
...
f6df640bd5
| Author | SHA1 | Date | |
|---|---|---|---|
| f6df640bd5 | |||
| 6f8195cda1 | |||
| 2ccfac2d04 | |||
| 99d28c2114 | |||
| 5863574a78 | |||
| 8cda54548f | |||
| 27143319e3 | |||
| 2465bc2ec3 | |||
| cb68451c1c |
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
backend/data
|
||||||
|
data
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
.DS_Store
|
||||||
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
APP_PORT=8080
|
||||||
|
IMAGE=repo.ssp-itinfra.com/admin/shooting-event:amd64-latest
|
||||||
|
ADMIN_USER=datwyler
|
||||||
|
ADMIN_PASS=datwyler
|
||||||
|
GEMINI_API_KEY=replace-with-your-key
|
||||||
|
GEMINI_MODEL=gemini-2.0-flash
|
||||||
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Node
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
# Go binaries
|
||||||
|
bin/
|
||||||
|
backend/backend
|
||||||
|
|
||||||
|
# Runtime/generated web assets
|
||||||
|
backend/web/
|
||||||
|
|
||||||
|
# SQLite runtime data
|
||||||
|
backend/data/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
182
API_CHANGES.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# API Changes (Not Yet Reflected in `backend/openapi/openapi.json`)
|
||||||
|
|
||||||
|
This document lists additive API changes currently implemented in the Go backend.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Existing endpoints remain compatible.
|
||||||
|
|
||||||
|
Added capabilities:
|
||||||
|
|
||||||
|
1. Position board persistence for live/admin ordering.
|
||||||
|
2. Stage session control (`start`/`end`) for event flow.
|
||||||
|
3. Stage history retrieval.
|
||||||
|
4. New state fields (`positionBoards`, `current_stage`, `last_stage`).
|
||||||
|
|
||||||
|
## 1) Position Board Endpoint
|
||||||
|
|
||||||
|
- Method: `PUT`
|
||||||
|
- Path: `/api/admin/positions/:board`
|
||||||
|
- Auth: admin bearer token (`Authorization: Bearer <token>`)
|
||||||
|
|
||||||
|
### Supported `:board` values
|
||||||
|
|
||||||
|
- `preliminary`
|
||||||
|
- `final`
|
||||||
|
- `prelim_tiebreak`
|
||||||
|
- `final_tiebreak`
|
||||||
|
|
||||||
|
### Request body
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"slots": [
|
||||||
|
{ "playerId": 12, "groupKey": "A", "position": 1 },
|
||||||
|
{ "playerId": 15, "groupKey": "A", "position": 2 },
|
||||||
|
{ "playerId": 20, "groupKey": "B", "position": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `playerId` (integer, required)
|
||||||
|
- `groupKey` (string, required)
|
||||||
|
- `position` (integer > 0, required)
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The request replaces all saved slots for the target board.
|
||||||
|
- For `preliminary`, `players.group_code` is synchronized from `groupKey`.
|
||||||
|
- Special value `UNASSIGNED` maps to empty player group.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
- `200 OK` with full updated admin state payload.
|
||||||
|
|
||||||
|
## 2) Stage Session Control Endpoints
|
||||||
|
|
||||||
|
### Start stage
|
||||||
|
|
||||||
|
- Method: `POST`
|
||||||
|
- Path: `/api/admin/stage/start`
|
||||||
|
- Auth: admin bearer token
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"phase": "preliminary",
|
||||||
|
"group": "A",
|
||||||
|
"round": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `phase` must be one of:
|
||||||
|
- `preliminary`
|
||||||
|
- `prelim_tiebreak`
|
||||||
|
- `final`
|
||||||
|
- `final_tiebreak`
|
||||||
|
- `group` validation depends on phase:
|
||||||
|
- `preliminary`: group code string (or `UNASSIGNED`)
|
||||||
|
- `final`: `"1"` or `"2"`
|
||||||
|
- tie-break phases: positive numeric string
|
||||||
|
- `round` normalization:
|
||||||
|
- `preliminary`: `1..3`
|
||||||
|
- `final`: `1..2`
|
||||||
|
- tie-break phases: always `1`
|
||||||
|
- If another stage is active (started, not ended), API returns `409`.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
- `200 OK` with full updated admin state payload.
|
||||||
|
|
||||||
|
### End stage
|
||||||
|
|
||||||
|
- Method: `POST`
|
||||||
|
- Path: `/api/admin/stage/end`
|
||||||
|
- Auth: admin bearer token
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Ends the currently active stage by setting `ended_at`.
|
||||||
|
- If no active stage exists, API returns `400`.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
- `200 OK` with full updated admin state payload.
|
||||||
|
|
||||||
|
## 3) Stage History Endpoint
|
||||||
|
|
||||||
|
- Method: `GET`
|
||||||
|
- Path: `/api/admin/stage/history`
|
||||||
|
- Auth: admin bearer token
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"phase": "preliminary",
|
||||||
|
"group": "A",
|
||||||
|
"round": 2,
|
||||||
|
"startedAt": "2026-04-30T08:27:13Z",
|
||||||
|
"endedAt": "2026-04-30T08:28:01Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4) New State Fields
|
||||||
|
|
||||||
|
Both endpoints now include these fields:
|
||||||
|
|
||||||
|
- `GET /api/state`
|
||||||
|
- `GET /api/admin/state`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"positionBoards": {
|
||||||
|
"preliminary": {
|
||||||
|
"12": { "groupKey": "A", "position": 1 }
|
||||||
|
},
|
||||||
|
"final": {
|
||||||
|
"12": { "groupKey": "1", "position": 3 }
|
||||||
|
},
|
||||||
|
"prelim_tiebreak": {},
|
||||||
|
"final_tiebreak": {}
|
||||||
|
},
|
||||||
|
"current_stage": {
|
||||||
|
"id": 7,
|
||||||
|
"phase": "preliminary",
|
||||||
|
"group": "A",
|
||||||
|
"round": 2,
|
||||||
|
"startedAt": "2026-04-30T09:00:00Z"
|
||||||
|
},
|
||||||
|
"last_stage": {
|
||||||
|
"id": 6,
|
||||||
|
"phase": "final",
|
||||||
|
"group": "1",
|
||||||
|
"round": 1,
|
||||||
|
"startedAt": "2026-04-30T08:45:00Z",
|
||||||
|
"endedAt": "2026-04-30T08:55:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `positionBoards` keys are player IDs as strings.
|
||||||
|
- `current_stage` is `null` when no stage is active.
|
||||||
|
- `last_stage` is `null` when no stage session has ever started.
|
||||||
|
- `last_stage` is the most recently started session (ended or still active).
|
||||||
|
|
||||||
|
## Compatibility Impact
|
||||||
|
|
||||||
|
- Additive only; existing consumers remain functional.
|
||||||
|
- Consumers can ignore unknown fields safely.
|
||||||
|
- Consumers needing control/monitoring can use new stage endpoints and fields.
|
||||||
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
# FROM --platform=$BUILDPLATFORM golang:1.24 AS backend-builder
|
||||||
|
# WORKDIR /app/backend
|
||||||
|
# COPY backend/go.mod backend/go.sum ./
|
||||||
|
# RUN go mod download
|
||||||
|
# COPY backend/ ./
|
||||||
|
# ARG TARGETARCH
|
||||||
|
# RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} go build -trimpath -ldflags="-s -w" -o /out/shooting-event .
|
||||||
|
|
||||||
|
FROM --platform=$TARGETPLATFORM alpine:3.21
|
||||||
|
RUN addgroup -S app && adduser -S app -G app && apk add --no-cache ca-certificates
|
||||||
|
WORKDIR /app
|
||||||
|
COPY bin/shooting-event-amd64 /app/shooting-event
|
||||||
|
COPY frontend/dist /app/web
|
||||||
|
RUN mkdir -p /app/data && chown -R app:app /app
|
||||||
|
USER app
|
||||||
|
ENV PORT=8080
|
||||||
|
ENV DB_PATH=/app/data/shooting.db
|
||||||
|
ENV WEB_DIR=/app/web
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/app/shooting-event"]
|
||||||
44
Dockerfile.phoenix
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||||
|
RUN corepack enable && pnpm install --frozen-lockfile
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM elixir:1.18.4-otp-28 AS phoenix-builder
|
||||||
|
ENV MIX_ENV=prod
|
||||||
|
WORKDIR /app/phoenix
|
||||||
|
|
||||||
|
RUN mix local.hex --force && mix local.rebar --force
|
||||||
|
|
||||||
|
COPY phoenix/mix.exs phoenix/mix.lock ./
|
||||||
|
RUN mix deps.get --only prod
|
||||||
|
RUN mix deps.compile
|
||||||
|
|
||||||
|
COPY phoenix/config ./config
|
||||||
|
COPY phoenix/lib ./lib
|
||||||
|
COPY phoenix/priv ./priv
|
||||||
|
RUN mix compile
|
||||||
|
RUN mix release
|
||||||
|
|
||||||
|
FROM --platform=$TARGETPLATFORM elixir:1.18.4-otp-28
|
||||||
|
RUN groupadd -r app && useradd -r -g app app
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=phoenix-builder /app/phoenix/_build/prod/rel/shooting_event_phx /app
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist /app/web
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data && chown -R app:app /app
|
||||||
|
USER app
|
||||||
|
|
||||||
|
ENV PHX_SERVER=true
|
||||||
|
ENV PORT=8080
|
||||||
|
ENV DB_PATH=/app/data/shooting.db
|
||||||
|
ENV WEB_DIR=/app/web
|
||||||
|
ENV SECRET_KEY_BASE=pxh_v1_V2Y3TXF4Rk9OUm5FdlVQc2s3M2hqd0t1Q2pwYUx4N3Vjb2I5Wk1qXzJ5RWRmQnJ
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/app/bin/shooting_event_phx"]
|
||||||
|
CMD ["start"]
|
||||||
83
Makefile
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
SHELL := /bin/bash
|
||||||
|
|
||||||
|
PNPM ?= pnpm
|
||||||
|
GO ?= go
|
||||||
|
CONTAINER_REG ?= repo.ssp-itinfra.com/admin
|
||||||
|
IMAGE_NAME ?= shooting-event-2
|
||||||
|
PHOENIX_IMAGE_NAME ?= $(IMAGE_NAME)-phoenix
|
||||||
|
ARCH ?= amd64
|
||||||
|
BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
BUILD_VERSION ?= $(shell git describe --tags --always --dirty)
|
||||||
|
|
||||||
|
.PHONY: install dev dev-backend dev-frontend build build-frontend build-backend phoenix-dev phoenix-build docker-build docker-run docker-build-phoenix docker-run-phoenix clean
|
||||||
|
|
||||||
|
install:
|
||||||
|
cd frontend && $(PNPM) install
|
||||||
|
cd backend && $(GO) mod tidy
|
||||||
|
cd phoenix && mix deps.get
|
||||||
|
|
||||||
|
dev-backend:
|
||||||
|
cd backend && $(GO) run .
|
||||||
|
|
||||||
|
dev-frontend:
|
||||||
|
cd frontend && $(PNPM) dev
|
||||||
|
|
||||||
|
dev:
|
||||||
|
@set -euo pipefail; \
|
||||||
|
trap 'kill 0' INT TERM EXIT; \
|
||||||
|
$(MAKE) dev-backend & \
|
||||||
|
$(MAKE) dev-frontend & \
|
||||||
|
wait
|
||||||
|
|
||||||
|
build-frontend:
|
||||||
|
cd frontend && $(PNPM) build
|
||||||
|
|
||||||
|
build-backend:
|
||||||
|
mkdir -p bin
|
||||||
|
cd backend && $(GO) build -o ../bin/shooting-event .
|
||||||
|
|
||||||
|
build-backend-amd64:
|
||||||
|
mkdir -p bin
|
||||||
|
cd backend && GOARCH=amd64 GOOS=linux $(GO) build -o ../bin/shooting-event-amd64 .
|
||||||
|
build: build-frontend
|
||||||
|
rm -rf backend/web
|
||||||
|
mkdir -p backend/web
|
||||||
|
cp -R frontend/dist/. backend/web/
|
||||||
|
$(MAKE) build-backend
|
||||||
|
|
||||||
|
phoenix-dev:
|
||||||
|
cd phoenix && mix deps.get && mix ecto.create && mix ecto.migrate && PORT=$${PORT:-8080} mix phx.server
|
||||||
|
|
||||||
|
phoenix-build:
|
||||||
|
cd phoenix && MIX_ENV=prod mix deps.get --only prod && MIX_ENV=prod mix compile
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker buildx build --load --platform=linux/$(ARCH) -t $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) .
|
||||||
|
docker tag $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-latest
|
||||||
|
docker tag $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
|
||||||
|
docker push $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)
|
||||||
|
docker push $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-latest
|
||||||
|
docker push $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
mkdir -p data
|
||||||
|
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)
|
||||||
|
|
||||||
|
docker-build-phoenix:
|
||||||
|
docker buildx build --load --platform=linux/$(ARCH) -f Dockerfile.phoenix -t $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) .
|
||||||
|
docker tag $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-latest
|
||||||
|
docker tag $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
|
||||||
|
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)
|
||||||
|
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-latest
|
||||||
|
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
|
||||||
|
|
||||||
|
docker-run-phoenix:
|
||||||
|
mkdir -p data
|
||||||
|
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)
|
||||||
|
|
||||||
|
release:
|
||||||
|
${MAKE} build
|
||||||
|
$(MAKE) docker-build
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin frontend/dist backend/web
|
||||||
161
README.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Datwyler Shooting Event System
|
||||||
|
|
||||||
|
Production-ready full-stack web app based on your original live score concept.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Frontend: Vue 3 + Vite (pnpm)
|
||||||
|
- Backend: Go + Echo
|
||||||
|
- Database: SQLite
|
||||||
|
- Packaging: Single Docker image (frontend + backend)
|
||||||
|
|
||||||
|
## Main Features
|
||||||
|
|
||||||
|
- Bilingual UI: Arabic and English
|
||||||
|
- Runtime RTL/LTR switching
|
||||||
|
- Admin avatar crop/fit tool (drag + zoom before saving)
|
||||||
|
- AI score advisor for proof images (Gemini-powered suggestion + optional apply)
|
||||||
|
- Two clean modes:
|
||||||
|
- View Only screen for players/coaches/audience
|
||||||
|
- Admin Control Panel (login required)
|
||||||
|
- Admin credentials (default):
|
||||||
|
- Username: `datwyler`
|
||||||
|
- Password: `datwyler`
|
||||||
|
|
||||||
|
## Tournament Flow Implemented
|
||||||
|
|
||||||
|
1. Admin registers players and assigns groups (no hard 6-player limit).
|
||||||
|
2. View screen shows group assignment clearly.
|
||||||
|
3. Admin enters preliminary scores.
|
||||||
|
4. Overall ranking auto-calculates and highlights top 12 finalists.
|
||||||
|
5. If rank #12 cutoff is tied, qualification tie-break stage appears.
|
||||||
|
6. Top 12 split into final groups (1-6 and 7-12 seeds).
|
||||||
|
7. Admin enters final scores.
|
||||||
|
8. Podium is determined automatically.
|
||||||
|
9. If top-3 tie exists, podium tie-break stage appears.
|
||||||
|
|
||||||
|
## API Documentation (OpenAPI)
|
||||||
|
|
||||||
|
Interactive docs are now built in:
|
||||||
|
|
||||||
|
- Swagger UI: `GET /api/docs`
|
||||||
|
- OpenAPI JSON: `GET /api/openapi.json`
|
||||||
|
|
||||||
|
### Testing with Auth in Swagger UI
|
||||||
|
|
||||||
|
1. Open `/api/docs`.
|
||||||
|
2. Use the **Login & Authorize** form at the top:
|
||||||
|
- Username: `datwyler` (default)
|
||||||
|
- Password: `datwyler` (default)
|
||||||
|
3. The page calls `POST /api/admin/login`, retrieves the token, and auto-authorizes Swagger.
|
||||||
|
4. Use **Try it out** on any admin endpoint.
|
||||||
|
|
||||||
|
### Stage Values
|
||||||
|
|
||||||
|
For score update/proof/advice endpoints (`/api/admin/scores/{stage}/...`), use:
|
||||||
|
|
||||||
|
- `prelim_r1`
|
||||||
|
- `prelim_r2`
|
||||||
|
- `prelim_r3`
|
||||||
|
- `final_r1`
|
||||||
|
- `final_r2`
|
||||||
|
- `prelim_tiebreak`
|
||||||
|
- `final_tiebreak`
|
||||||
|
|
||||||
|
For reset endpoint (`POST /api/admin/scores/{stage}/reset`), use:
|
||||||
|
|
||||||
|
- `preliminary`
|
||||||
|
- `final`
|
||||||
|
- `prelim_tiebreak`
|
||||||
|
- `final_tiebreak`
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
Run backend + frontend together:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Backend dev port is `18081`.
|
||||||
|
- Frontend runs on `5173` (or next free port, e.g. `5174` if busy).
|
||||||
|
- Frontend proxy is configured so `/api/*` works from Vite dev server.
|
||||||
|
|
||||||
|
Run individually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev-backend
|
||||||
|
make dev-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds frontend assets, copies them into backend `web/`, and compiles backend binary.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Build image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker-build ARCH=amd64
|
||||||
|
# or
|
||||||
|
make docker-build ARCH=arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
Run image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker-run ARCH=amd64
|
||||||
|
# or
|
||||||
|
make docker-run ARCH=arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose (Production)
|
||||||
|
|
||||||
|
1. Copy environment template:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `.env` for production credentials/tag.
|
||||||
|
|
||||||
|
3. Start service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Check health:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
5. View logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime Environment Variables
|
||||||
|
|
||||||
|
- `PORT` (default `8080`)
|
||||||
|
- `DB_PATH` (default `./data/shooting.db`)
|
||||||
|
- `WEB_DIR` (default `./web`)
|
||||||
|
- `ADMIN_USER` (default `datwyler`)
|
||||||
|
- `ADMIN_PASS` (default `datwyler`)
|
||||||
|
- `GEMINI_API_KEY` (required for AI score advisor)
|
||||||
|
- `GEMINI_MODEL` (default `gemini-2.0-flash`)
|
||||||
1
backend/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
GEMINI_API_KEY=AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc
|
||||||
95
backend/ai_handlers.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) handleScoreAdvice(c echo.Context) error {
|
||||||
|
stage, err := validateStage(c.Param("stage"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(a.cfg.GeminiAPIKey) == "" {
|
||||||
|
return writeError(c, http.StatusServiceUnavailable, "gemini api key is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
proofImageData, err := a.loadScoreProofImage(stage, playerID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return writeError(c, http.StatusBadRequest, "no proof image found for this score")
|
||||||
|
}
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("load proof image: %v", err))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(proofImageData) == "" {
|
||||||
|
return writeError(c, http.StatusBadRequest, "no proof image found for this score")
|
||||||
|
}
|
||||||
|
|
||||||
|
currentScore, _ := a.loadCurrentScore(stage, playerID)
|
||||||
|
|
||||||
|
advice, err := a.generateScoreAdvice(stage, playerID, proofImageData, currentScore)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadGateway, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, advice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loadScoreProofImage(stage string, playerID int) (string, error) {
|
||||||
|
var imageData string
|
||||||
|
err := a.db.QueryRow(`SELECT image_data FROM score_attachments WHERE stage = ? AND player_id = ?`, stage, playerID).Scan(&imageData)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return imageData, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) loadCurrentScore(stage string, playerID int) (int, error) {
|
||||||
|
var score int
|
||||||
|
err := a.db.QueryRow(`SELECT score FROM scores WHERE stage = ? AND player_id = ?`, stage, playerID).Scan(&score)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return score, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) buildAdviceResponse(stage string, playerID int, raw scoreAdviceModelResponse) ScoreAdviceResponse {
|
||||||
|
advised := clampInt(raw.AdvisedScore, 0, 100)
|
||||||
|
reason := strings.TrimSpace(raw.Reason)
|
||||||
|
if reason == "" {
|
||||||
|
reason = "AI estimated the score from visible impacts."
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScoreAdviceResponse{
|
||||||
|
Stage: stage,
|
||||||
|
PlayerID: playerID,
|
||||||
|
AdvisedScore: advised,
|
||||||
|
Reason: reason,
|
||||||
|
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampInt(value, min, max int) int {
|
||||||
|
if value < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if value > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
145
backend/api_docs.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed openapi/openapi.json
|
||||||
|
var openAPISpecJSON string
|
||||||
|
|
||||||
|
func (a *App) handleOpenAPISpec(c echo.Context) error {
|
||||||
|
return c.Blob(http.StatusOK, "application/json; charset=utf-8", []byte(openAPISpecJSON))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleOpenAPIDocs(c echo.Context) error {
|
||||||
|
html := fmt.Sprintf(`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Shooting Event API Docs</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
.docs-top {
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #dde5f3;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.docs-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #1b1f40;
|
||||||
|
}
|
||||||
|
.docs-help {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4c5773;
|
||||||
|
}
|
||||||
|
.auth-panel {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.auth-panel input {
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid #cdd7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.auth-panel button {
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid #20284f;
|
||||||
|
background: #20284f;
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.auth-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #38446b;
|
||||||
|
}
|
||||||
|
#swagger-ui {
|
||||||
|
max-width: 1300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section class="docs-top">
|
||||||
|
<h1 class="docs-title">Shooting Event OpenAPI Docs</h1>
|
||||||
|
<p class="docs-help">
|
||||||
|
Use <strong>Try it out</strong> for live testing. For admin endpoints: login with the form below,
|
||||||
|
then your token is auto-attached to Swagger Authorize.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="auth-form" class="auth-panel">
|
||||||
|
<input id="admin-user" type="text" value="datwyler" autocomplete="username" placeholder="Admin username" />
|
||||||
|
<input id="admin-pass" type="password" value="datwyler" autocomplete="current-password" placeholder="Admin password" />
|
||||||
|
<button type="submit">Login & Authorize</button>
|
||||||
|
<span id="auth-status" class="auth-status"></span>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
const ui = SwaggerUIBundle({
|
||||||
|
url: %q,
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
displayRequestDuration: true,
|
||||||
|
persistAuthorization: true,
|
||||||
|
docExpansion: 'list',
|
||||||
|
tryItOutEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loginAndAuthorize(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const status = document.getElementById('auth-status');
|
||||||
|
status.textContent = 'Logging in...';
|
||||||
|
|
||||||
|
const username = document.getElementById('admin-user').value || '';
|
||||||
|
const password = document.getElementById('admin-pass').value || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.message || 'Login failed');
|
||||||
|
}
|
||||||
|
if (!payload.token) {
|
||||||
|
throw new Error('Login succeeded but token was not returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.preauthorizeApiKey('bearerAuth', payload.token);
|
||||||
|
status.textContent = 'Authorized until ' + (payload.expiresAt || 'token expiry');
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = 'Auth failed: ' + error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('auth-form').addEventListener('submit', loginAndAuthorize);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`, "/api/openapi.json")
|
||||||
|
|
||||||
|
return c.HTML(http.StatusOK, html)
|
||||||
|
}
|
||||||
93
backend/auth.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) isAdminRequest(c echo.Context) bool {
|
||||||
|
header := strings.TrimSpace(c.Request().Header.Get(echo.HeaderAuthorization))
|
||||||
|
if header == "" || !strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(header[7:])
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return a.sessions.ValidateToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) adminOnly(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
a.sessions.PurgeExpired()
|
||||||
|
|
||||||
|
header := strings.TrimSpace(c.Request().Header.Get(echo.HeaderAuthorization))
|
||||||
|
if header == "" || !strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
||||||
|
return writeError(c, http.StatusUnauthorized, "missing admin token")
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(header[7:])
|
||||||
|
if token == "" || !a.sessions.ValidateToken(token) {
|
||||||
|
return writeError(c, http.StatusUnauthorized, "invalid or expired admin token")
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) verifyAdmin(username, password string) bool {
|
||||||
|
u := strings.TrimSpace(username)
|
||||||
|
p := strings.TrimSpace(password)
|
||||||
|
if u == "" || p == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
userMatch := subtle.ConstantTimeCompare([]byte(u), []byte(a.cfg.AdminUser)) == 1
|
||||||
|
passMatch := subtle.ConstantTimeCompare([]byte(p), []byte(a.cfg.AdminPass)) == 1
|
||||||
|
return userMatch && passMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) CreateToken() (string, time.Time, error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
token := hex.EncodeToString(raw)
|
||||||
|
expires := time.Now().UTC().Add(s.duration)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.tokens[token] = expires
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return token, expires, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) ValidateToken(token string) bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
expires, ok := s.tokens[token]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().UTC().Before(expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) DeleteToken(token string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.tokens, token)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionStore) PurgeExpired() {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
s.mu.Lock()
|
||||||
|
for token, expiry := range s.tokens {
|
||||||
|
if now.After(expiry) {
|
||||||
|
delete(s.tokens, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
36
backend/config.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
DBPath string
|
||||||
|
WebDir string
|
||||||
|
AdminUser string
|
||||||
|
AdminPass string
|
||||||
|
GeminiAPIKey string
|
||||||
|
GeminiModel string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Port: envOrDefault("PORT", "8080"),
|
||||||
|
DBPath: envOrDefault("DB_PATH", "./data/shooting.db"),
|
||||||
|
WebDir: envOrDefault("WEB_DIR", "./web"),
|
||||||
|
AdminUser: envOrDefault("ADMIN_USER", "datwyler"),
|
||||||
|
AdminPass: envOrDefault("ADMIN_PASS", "datwyler"),
|
||||||
|
GeminiAPIKey: envOrDefault("GEMINI_API_KEY", "AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc"),
|
||||||
|
GeminiModel: envOrDefault("GEMINI_MODEL", "gemini-3.1-flash-lite-preview"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOrDefault(key, fallback string) string {
|
||||||
|
v := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
118
backend/db.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func initDB(cfg Config) (*sql.DB, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create data dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
|
||||||
|
if _, err := db.Exec(`
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA busy_timeout = 5000;
|
||||||
|
`); err != nil {
|
||||||
|
return nil, fmt.Errorf("sqlite pragmas: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS players (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name_ar TEXT NOT NULL,
|
||||||
|
name_en TEXT NOT NULL,
|
||||||
|
group_code TEXT NOT NULL DEFAULT '',
|
||||||
|
image_data TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scores (
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY(stage, player_id),
|
||||||
|
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS score_attachments (
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
image_data TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY(stage, player_id),
|
||||||
|
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_positions (
|
||||||
|
board TEXT NOT NULL,
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
group_key TEXT NOT NULL DEFAULT '',
|
||||||
|
position INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY(board, player_id),
|
||||||
|
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stage_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
phase TEXT NOT NULL,
|
||||||
|
group_key TEXT NOT NULL DEFAULT '',
|
||||||
|
round INTEGER NOT NULL DEFAULT 1,
|
||||||
|
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ended_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO app_settings(key, value) VALUES
|
||||||
|
('view_proof_in_view', '0'),
|
||||||
|
('live_active_view', 'group_live'),
|
||||||
|
('live_show_group_live', '1'),
|
||||||
|
('live_group_display_mode', 'rotate'),
|
||||||
|
('live_group_fixed_code', ''),
|
||||||
|
('live_show_prelim_tie', '1'),
|
||||||
|
('live_show_prelim_overall', '1'),
|
||||||
|
('live_show_final_groups', '1'),
|
||||||
|
('live_final_display_mode', 'rotate'),
|
||||||
|
('live_final_fixed_group', '1'),
|
||||||
|
('live_show_final_tie', '1'),
|
||||||
|
('live_show_podium', '1'),
|
||||||
|
('live_rotation_interval_sec', '5'),
|
||||||
|
('live_rotation_player_count', '12');
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO scores(stage, player_id, score)
|
||||||
|
SELECT 'prelim_r1', player_id, score FROM scores WHERE stage = 'preliminary';
|
||||||
|
INSERT OR IGNORE INTO scores(stage, player_id, score)
|
||||||
|
SELECT 'final_r1', player_id, score FROM scores WHERE stage = 'final';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
|
||||||
|
SELECT 'prelim_r1', player_id, image_data FROM score_attachments WHERE stage = 'preliminary';
|
||||||
|
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
|
||||||
|
SELECT 'final_r1', player_id, image_data FROM score_attachments WHERE stage = 'final';
|
||||||
|
`
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
return nil, fmt.Errorf("apply schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
83
backend/events.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) handleEvents(c echo.Context) error {
|
||||||
|
res := c.Response()
|
||||||
|
req := c.Request()
|
||||||
|
|
||||||
|
res.Header().Set(echo.HeaderContentType, "text/event-stream")
|
||||||
|
res.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||||
|
res.Header().Set("Connection", "keep-alive")
|
||||||
|
res.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
res.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
flusher, ok := res.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "streaming not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprintf(res, "event: ready\ndata: {\"ts\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
id, events := a.events.Subscribe()
|
||||||
|
defer a.events.Unsubscribe(id)
|
||||||
|
|
||||||
|
keepAlive := time.NewTicker(20 * time.Second)
|
||||||
|
defer keepAlive.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-req.Context().Done():
|
||||||
|
return nil
|
||||||
|
case <-events:
|
||||||
|
if _, err := fmt.Fprintf(res, "event: state\ndata: {\"ts\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
case t := <-keepAlive.C:
|
||||||
|
if _, err := fmt.Fprintf(res, ": ping %d\n\n", t.Unix()); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EventHub) Subscribe() (int, <-chan struct{}) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.nextID++
|
||||||
|
id := h.nextID
|
||||||
|
ch := make(chan struct{}, 1)
|
||||||
|
h.subscribers[id] = ch
|
||||||
|
return id, ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EventHub) Unsubscribe(id int) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
if ch, ok := h.subscribers[id]; ok {
|
||||||
|
delete(h.subscribers, id)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *EventHub) Broadcast() {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for _, ch := range h.subscribers {
|
||||||
|
select {
|
||||||
|
case ch <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
203
backend/gemini.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type scoreAdviceModelResponse struct {
|
||||||
|
AdvisedScore int `json:"advisedScore"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiGenerateRequest struct {
|
||||||
|
Contents []geminiContent `json:"contents"`
|
||||||
|
GenerationConfig geminiGenerationConfig `json:"generationConfig,omitempty"`
|
||||||
|
SafetySettings []map[string]interface{} `json:"safetySettings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiContent struct {
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Parts []geminiPart `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiPart struct {
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
InlineData *geminiInlineData `json:"inline_data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiInlineData struct {
|
||||||
|
MimeType string `json:"mime_type"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiGenerationConfig struct {
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
ResponseMimeType string `json:"responseMimeType,omitempty"`
|
||||||
|
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geminiGenerateResponse struct {
|
||||||
|
Candidates []struct {
|
||||||
|
Content struct {
|
||||||
|
Parts []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"parts"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"candidates"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) generateScoreAdvice(stage string, playerID int, imageData string, currentScore int) (ScoreAdviceResponse, error) {
|
||||||
|
mimeType, rawBase64, err := parseDataURI(imageData)
|
||||||
|
if err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("invalid proof image data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model := strings.TrimSpace(a.cfg.GeminiModel)
|
||||||
|
if model == "" {
|
||||||
|
model = "gemini-2.0-flash"
|
||||||
|
}
|
||||||
|
|
||||||
|
requestPayload := geminiGenerateRequest{
|
||||||
|
Contents: []geminiContent{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Parts: []geminiPart{
|
||||||
|
{Text: scoreAdvicePrompt(stage, currentScore)},
|
||||||
|
{
|
||||||
|
InlineData: &geminiInlineData{
|
||||||
|
MimeType: mimeType,
|
||||||
|
Data: rawBase64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
GenerationConfig: geminiGenerationConfig{
|
||||||
|
Temperature: 0,
|
||||||
|
ResponseMimeType: "application/json",
|
||||||
|
MaxOutputTokens: 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(requestPayload)
|
||||||
|
if err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("marshal gemini request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := fmt.Sprintf(
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s",
|
||||||
|
url.PathEscape(model),
|
||||||
|
url.QueryEscape(a.cfg.GeminiAPIKey),
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("create gemini request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 25 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("call gemini api: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2_000_000))
|
||||||
|
if err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("read gemini response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("gemini api status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var generated geminiGenerateResponse
|
||||||
|
if err := json.Unmarshal(respBody, &generated); err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("decode gemini response: %w", err)
|
||||||
|
}
|
||||||
|
if generated.Error != nil && strings.TrimSpace(generated.Error.Message) != "" {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("gemini api error: %s", generated.Error.Message)
|
||||||
|
}
|
||||||
|
if len(generated.Candidates) == 0 || len(generated.Candidates[0].Content.Parts) == 0 {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("gemini returned no advice")
|
||||||
|
}
|
||||||
|
|
||||||
|
rawText := strings.TrimSpace(generated.Candidates[0].Content.Parts[0].Text)
|
||||||
|
jsonText := extractJSONObject(rawText)
|
||||||
|
if jsonText == "" {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("gemini response was not valid json")
|
||||||
|
}
|
||||||
|
|
||||||
|
var modelResponse scoreAdviceModelResponse
|
||||||
|
if err := json.Unmarshal([]byte(jsonText), &modelResponse); err != nil {
|
||||||
|
return ScoreAdviceResponse{}, fmt.Errorf("parse gemini advice json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.buildAdviceResponse(stage, playerID, modelResponse), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDataURI(dataURI string) (string, string, error) {
|
||||||
|
value := strings.TrimSpace(dataURI)
|
||||||
|
if !strings.HasPrefix(value, "data:") {
|
||||||
|
return "", "", fmt.Errorf("expected data uri")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(value, ",", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", "", fmt.Errorf("invalid data uri format")
|
||||||
|
}
|
||||||
|
|
||||||
|
header := parts[0]
|
||||||
|
payload := strings.TrimSpace(parts[1])
|
||||||
|
if payload == "" {
|
||||||
|
return "", "", fmt.Errorf("empty data uri payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeType := "image/jpeg"
|
||||||
|
if semicolon := strings.Index(header, ";"); semicolon > len("data:") {
|
||||||
|
mimeType = strings.TrimSpace(header[len("data:"):semicolon])
|
||||||
|
}
|
||||||
|
if mimeType == "" {
|
||||||
|
mimeType = "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
return mimeType, payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractJSONObject(raw string) string {
|
||||||
|
text := strings.TrimSpace(raw)
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(text, "```") {
|
||||||
|
text = strings.TrimPrefix(text, "```json")
|
||||||
|
text = strings.TrimPrefix(text, "```")
|
||||||
|
text = strings.TrimSuffix(strings.TrimSpace(text), "```")
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
start := strings.Index(text, "{")
|
||||||
|
end := strings.LastIndex(text, "}")
|
||||||
|
if start == -1 || end == -1 || end <= start {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(text[start : end+1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreAdvicePrompt(stage string, currentScore int) string {
|
||||||
|
return fmt.Sprintf(`Target scoring assistant.
|
||||||
|
Stage: %s. Current score: %d.
|
||||||
|
Return STRICT JSON only:
|
||||||
|
{"advisedScore":<int 0..100>,"reason":"<max 18 words>"}
|
||||||
|
Do not add markdown or extra fields.`, stage, currentScore)
|
||||||
|
}
|
||||||
29
backend/go.mod
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
module shooting-event/backend
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/labstack/echo/v4 v4.13.4
|
||||||
|
modernc.org/sqlite v1.39.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
golang.org/x/crypto v0.38.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/net v0.40.0 // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.25.0 // indirect
|
||||||
|
golang.org/x/time v0.11.0 // indirect
|
||||||
|
modernc.org/libc v1.66.10 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
75
backend/go.sum
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||||
|
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
||||||
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
|
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
|
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||||
|
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
||||||
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
|
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
|
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
|
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
|
||||||
|
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
643
backend/handlers.go
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) handleHealth(c echo.Context) error {
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleGetState(c echo.Context) error {
|
||||||
|
state, err := a.readState(a.isAdminRequest(c))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleAdminLogin(c echo.Context) error {
|
||||||
|
var req AdminLoginRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !a.verifyAdmin(req.Username, req.Password) {
|
||||||
|
return writeError(c, http.StatusUnauthorized, "invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
token, expiry, err := a.sessions.CreateToken()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to create session")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{
|
||||||
|
"token": token,
|
||||||
|
"expiresAt": expiry.Format(time.RFC3339),
|
||||||
|
"username": a.cfg.AdminUser,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleAdminLogout(c echo.Context) error {
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(c.Request().Header.Get(echo.HeaderAuthorization), "Bearer "))
|
||||||
|
a.sessions.DeleteToken(token)
|
||||||
|
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleUpdateAdminSettings(c echo.Context) error {
|
||||||
|
var req AdminSettingsUpdateRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
if req.ViewProofInView == nil && req.LiveMode == nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "at least one settings field is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.updateSettings(req); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "no settings to update") {
|
||||||
|
return writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleStartCurrentStage(c echo.Context) error {
|
||||||
|
var req StageControlStartRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
phase, ok := normalizeStagePhase(req.Phase)
|
||||||
|
if !ok {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid phase")
|
||||||
|
}
|
||||||
|
groupKey, ok := normalizeStageGroup(phase, req.GroupKey)
|
||||||
|
if !ok {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid group for selected phase")
|
||||||
|
}
|
||||||
|
round := normalizeStageRound(phase, req.Round)
|
||||||
|
|
||||||
|
current, err := a.readCurrentStage()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
if current != nil {
|
||||||
|
return writeError(c, http.StatusConflict, "there is already an active stage. end it first")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = a.db.Exec(`
|
||||||
|
INSERT INTO stage_sessions(phase, group_key, round, started_at)
|
||||||
|
VALUES(?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
`, phase, groupKey, round)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("start stage: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleEndCurrentStage(c echo.Context) error {
|
||||||
|
current, err := a.readCurrentStage()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
if current == nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "no active stage to end")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = a.db.Exec(`
|
||||||
|
UPDATE stage_sessions
|
||||||
|
SET ended_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND ended_at IS NULL
|
||||||
|
`, current.ID)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("end stage: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleGetStageHistory(c echo.Context) error {
|
||||||
|
items, err := a.readStageHistory()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{
|
||||||
|
"items": items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleCreatePlayer(c echo.Context) error {
|
||||||
|
var req PlayerCreateRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
nameAr := strings.TrimSpace(req.NameAr)
|
||||||
|
nameEn := strings.TrimSpace(req.NameEn)
|
||||||
|
group := normalizeGroup(req.GroupCode)
|
||||||
|
if nameAr == "" || nameEn == "" {
|
||||||
|
return writeError(c, http.StatusBadRequest, "both Arabic and English names are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := a.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to start transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
res, err := tx.Exec(`
|
||||||
|
INSERT INTO players(name_ar, name_en, group_code, image_data)
|
||||||
|
VALUES(?, ?, ?, '')
|
||||||
|
`, nameAr, nameEn, group)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("create player: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
id64, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to read new player id")
|
||||||
|
}
|
||||||
|
playerID := int(id64)
|
||||||
|
|
||||||
|
for _, stage := range scoreStages {
|
||||||
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO scores(stage, player_id, score) VALUES(?, ?, 0)`, stage, playerID); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("create score row: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to commit create player")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusCreated, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleUpdatePlayer(c echo.Context) error {
|
||||||
|
playerID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req PlayerUpdateRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := []string{}
|
||||||
|
args := []any{}
|
||||||
|
|
||||||
|
if req.NameAr != nil {
|
||||||
|
nameAr := strings.TrimSpace(*req.NameAr)
|
||||||
|
if nameAr == "" {
|
||||||
|
return writeError(c, http.StatusBadRequest, "arabic name cannot be empty")
|
||||||
|
}
|
||||||
|
updates = append(updates, "name_ar = ?")
|
||||||
|
args = append(args, nameAr)
|
||||||
|
}
|
||||||
|
if req.NameEn != nil {
|
||||||
|
nameEn := strings.TrimSpace(*req.NameEn)
|
||||||
|
if nameEn == "" {
|
||||||
|
return writeError(c, http.StatusBadRequest, "english name cannot be empty")
|
||||||
|
}
|
||||||
|
updates = append(updates, "name_en = ?")
|
||||||
|
args = append(args, nameEn)
|
||||||
|
}
|
||||||
|
if req.GroupCode != nil {
|
||||||
|
updates = append(updates, "group_code = ?")
|
||||||
|
args = append(args, normalizeGroup(*req.GroupCode))
|
||||||
|
}
|
||||||
|
if req.ImageData != nil {
|
||||||
|
img := strings.TrimSpace(*req.ImageData)
|
||||||
|
if len(img) > 2_500_000 {
|
||||||
|
return writeError(c, http.StatusBadRequest, "image payload too large")
|
||||||
|
}
|
||||||
|
updates = append(updates, "image_data = ?")
|
||||||
|
args = append(args, img)
|
||||||
|
}
|
||||||
|
if req.ClearImage {
|
||||||
|
updates = append(updates, "image_data = ''")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return writeError(c, http.StatusBadRequest, "no fields to update")
|
||||||
|
}
|
||||||
|
|
||||||
|
updates = append(updates, "updated_at = CURRENT_TIMESTAMP")
|
||||||
|
args = append(args, playerID)
|
||||||
|
|
||||||
|
query := fmt.Sprintf("UPDATE players SET %s WHERE id = ?", strings.Join(updates, ", "))
|
||||||
|
res, err := a.db.Exec(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update player: %v", err))
|
||||||
|
}
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to check update result")
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return writeError(c, http.StatusNotFound, "player not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleDeletePlayer(c echo.Context) error {
|
||||||
|
playerID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := a.db.Exec(`DELETE FROM players WHERE id = ?`, playerID)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("delete player: %v", err))
|
||||||
|
}
|
||||||
|
affected, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to check delete result")
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return writeError(c, http.StatusNotFound, "player not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleAutoGroupPlayers(c echo.Context) error {
|
||||||
|
var req AutoGroupPlayersRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := normalizeGroups(req.Groups)
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return writeError(c, http.StatusBadRequest, "at least one group is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := a.db.Query(`SELECT id FROM players ORDER BY id ASC`)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("query players: %v", err))
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
playerIDs := []int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("scan player: %v", err))
|
||||||
|
}
|
||||||
|
playerIDs = append(playerIDs, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("read players: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
rng.Shuffle(len(playerIDs), func(i, j int) {
|
||||||
|
playerIDs[i], playerIDs[j] = playerIDs[j], playerIDs[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
tx, err := a.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to start transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for i, playerID := range playerIDs {
|
||||||
|
groupCode := groups[i%len(groups)]
|
||||||
|
if _, err := tx.Exec(`UPDATE players SET group_code = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, groupCode, playerID); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("assign player group: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to commit group assignment")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleUpdatePositionBoard(c echo.Context) error {
|
||||||
|
board, ok := normalizePositionBoard(c.Param("board"))
|
||||||
|
if !ok {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid position board")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req PositionBoardUpdateRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[int]bool{}
|
||||||
|
for _, slot := range req.Slots {
|
||||||
|
if slot.PlayerID <= 0 {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||||
|
}
|
||||||
|
if seen[slot.PlayerID] {
|
||||||
|
return writeError(c, http.StatusBadRequest, "duplicate player id in slots")
|
||||||
|
}
|
||||||
|
seen[slot.PlayerID] = true
|
||||||
|
if strings.TrimSpace(slot.GroupKey) == "" {
|
||||||
|
return writeError(c, http.StatusBadRequest, "groupKey is required")
|
||||||
|
}
|
||||||
|
if slot.Position <= 0 {
|
||||||
|
return writeError(c, http.StatusBadRequest, "position must be positive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := a.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to start position transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM player_positions WHERE board = ?`, board); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("clear board positions: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, slot := range req.Slots {
|
||||||
|
groupKey := normalizePositionGroupKey(board, slot.GroupKey, "")
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
INSERT INTO player_positions(board, player_id, group_key, position, updated_at)
|
||||||
|
VALUES(?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
`, board, slot.PlayerID, groupKey, slot.Position); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("save board position: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if board == positionBoardPreliminary {
|
||||||
|
groupCode := strings.TrimSpace(groupKey)
|
||||||
|
if strings.EqualFold(groupCode, positionBoardUnassigned) {
|
||||||
|
groupCode = ""
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`UPDATE players SET group_code = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, groupCode, slot.PlayerID); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update player group from board: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to commit positions")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleUpdateScore(c echo.Context) error {
|
||||||
|
stage, err := validateStage(c.Param("stage"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ScoreUpdateRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
if req.Score < 0 || req.Score > 100 {
|
||||||
|
return writeError(c, http.StatusBadRequest, "score must be between 0 and 100")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := a.db.Exec(`
|
||||||
|
INSERT INTO scores(stage, player_id, score)
|
||||||
|
VALUES(?, ?, ?)
|
||||||
|
ON CONFLICT(stage, player_id) DO UPDATE SET score = excluded.score, updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, stage, playerID, req.Score)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update score: %v", err))
|
||||||
|
}
|
||||||
|
if _, err := res.RowsAffected(); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to check score update")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleResetStageScores(c echo.Context) error {
|
||||||
|
targetStages, err := resolveResetStages(c.Param("stage"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ResetStageRequest
|
||||||
|
if err := c.Bind(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := a.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, "failed to start reset transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for _, stage := range targetStages {
|
||||||
|
if _, err := tx.Exec(`UPDATE scores SET score = 0, updated_at = CURRENT_TIMESTAMP WHERE stage = ?`, stage); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage scores: %v", err))
|
||||||
|
}
|
||||||
|
if req.ResetProofs {
|
||||||
|
if _, err := tx.Exec(`DELETE FROM score_attachments WHERE stage = ?`, stage); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage proofs: %v", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("commit stage reset: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveResetStages(stage string) ([]string, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(stage)) {
|
||||||
|
case "preliminary":
|
||||||
|
return append([]string{}, preliminaryRoundStages...), nil
|
||||||
|
case "final":
|
||||||
|
stages := append([]string{}, finalRoundStages...)
|
||||||
|
stages = append(stages, "final_tiebreak")
|
||||||
|
return stages, nil
|
||||||
|
case "prelim_tiebreak", "final_tiebreak":
|
||||||
|
normalized, err := validateStage(stage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []string{normalized}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid stage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleUpdateScoreProof(c echo.Context) error {
|
||||||
|
stage, err := validateStage(c.Param("stage"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req ScoreProofUpdateRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
imageData := strings.TrimSpace(req.ImageData)
|
||||||
|
if imageData == "" {
|
||||||
|
return writeError(c, http.StatusBadRequest, "imageData is required")
|
||||||
|
}
|
||||||
|
if len(imageData) > 5_000_000 {
|
||||||
|
return writeError(c, http.StatusBadRequest, "image payload too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.db.Exec(`
|
||||||
|
INSERT INTO score_attachments(stage, player_id, image_data)
|
||||||
|
VALUES(?, ?, ?)
|
||||||
|
ON CONFLICT(stage, player_id) DO UPDATE SET image_data = excluded.image_data, updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, stage, playerID, imageData); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update score proof: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleDeleteScoreProof(c echo.Context) error {
|
||||||
|
stage, err := validateStage(c.Param("stage"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
playerID, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.db.Exec(`DELETE FROM score_attachments WHERE stage = ? AND player_id = ?`, stage, playerID); err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("delete score proof: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
a.events.Broadcast()
|
||||||
|
|
||||||
|
state, err := a.readState(true)
|
||||||
|
if err != nil {
|
||||||
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateStage(stage string) (string, error) {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(stage))
|
||||||
|
for _, allowed := range scoreStages {
|
||||||
|
if normalized == allowed {
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("invalid stage")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGroup(group string) string {
|
||||||
|
return strings.TrimSpace(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGroups(groups []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []string{}
|
||||||
|
for _, group := range groups {
|
||||||
|
normalized := normalizeGroup(group)
|
||||||
|
if normalized == "" || seen[normalized] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[normalized] = true
|
||||||
|
out = append(out, normalized)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(c echo.Context, status int, message string) error {
|
||||||
|
return c.JSON(status, map[string]string{"message": message})
|
||||||
|
}
|
||||||
54
backend/main.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
db, err := initDB(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("init db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
app := &App{
|
||||||
|
db: db,
|
||||||
|
cfg: cfg,
|
||||||
|
sessions: &SessionStore{
|
||||||
|
tokens: map[string]time.Time{},
|
||||||
|
duration: 8 * time.Hour,
|
||||||
|
},
|
||||||
|
events: &EventHub{
|
||||||
|
subscribers: map[int]chan struct{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
e.HideBanner = true
|
||||||
|
e.HidePort = true
|
||||||
|
e.Use(middleware.Recover())
|
||||||
|
e.Use(middleware.Logger())
|
||||||
|
e.Use(middleware.RequestID())
|
||||||
|
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||||
|
AllowOrigins: []string{"*"},
|
||||||
|
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions},
|
||||||
|
AllowHeaders: []string{echo.HeaderContentType, echo.HeaderAuthorization},
|
||||||
|
}))
|
||||||
|
|
||||||
|
registerAPIRoutes(e, app)
|
||||||
|
registerWebRoutes(e, cfg)
|
||||||
|
|
||||||
|
addr := ":" + cfg.Port
|
||||||
|
log.Printf("listening on %s", addr)
|
||||||
|
if err := e.Start(addr); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
212
backend/models.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var preliminaryRoundStages = []string{"prelim_r1", "prelim_r2", "prelim_r3"}
|
||||||
|
var finalRoundStages = []string{"final_r1", "final_r2"}
|
||||||
|
var tieBreakStages = []string{"prelim_tiebreak", "final_tiebreak"}
|
||||||
|
|
||||||
|
var scoreStages = append(append(append([]string{}, preliminaryRoundStages...), finalRoundStages...), tieBreakStages...)
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
db *sql.DB
|
||||||
|
cfg Config
|
||||||
|
sessions *SessionStore
|
||||||
|
events *EventHub
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
tokens map[string]time.Time
|
||||||
|
duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventHub struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
nextID int
|
||||||
|
subscribers map[int]chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompetitionMeta struct {
|
||||||
|
TitleAr string `json:"titleAr"`
|
||||||
|
TitleEn string `json:"titleEn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Player struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
NameAr string `json:"nameAr"`
|
||||||
|
NameEn string `json:"nameEn"`
|
||||||
|
GroupCode string `json:"groupCode"`
|
||||||
|
ImageData string `json:"imageData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RankingRow struct {
|
||||||
|
PlayerID int `json:"playerId"`
|
||||||
|
NameAr string `json:"nameAr"`
|
||||||
|
NameEn string `json:"nameEn"`
|
||||||
|
GroupCode string `json:"groupCode"`
|
||||||
|
ImageData string `json:"imageData"`
|
||||||
|
Score int `json:"score"`
|
||||||
|
TieBreak int `json:"tieBreak"`
|
||||||
|
Rank int `json:"rank"`
|
||||||
|
Seed int `json:"seed"`
|
||||||
|
FinalGroup int `json:"finalGroup"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TieBreakInfo struct {
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Resolved bool `json:"resolved"`
|
||||||
|
Slots int `json:"slots"`
|
||||||
|
PlayerIDs []int `json:"playerIds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DerivedState struct {
|
||||||
|
PreliminaryRanking RankingBundle `json:"preliminaryRanking"`
|
||||||
|
Finalists []RankingRow `json:"finalists"`
|
||||||
|
FinalGroups FinalGroups `json:"finalGroups"`
|
||||||
|
FinalRanking RankingBundle `json:"finalRanking"`
|
||||||
|
Podium []RankingRow `json:"podium"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RankingBundle struct {
|
||||||
|
Rows []RankingRow `json:"rows"`
|
||||||
|
TieBreak TieBreakInfo `json:"tieBreak"`
|
||||||
|
Unresolved bool `json:"unresolved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinalGroups struct {
|
||||||
|
Group1 []RankingRow `json:"group1"`
|
||||||
|
Group2 []RankingRow `json:"group2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StateResponse struct {
|
||||||
|
Competition CompetitionMeta `json:"competition"`
|
||||||
|
Players []Player `json:"players"`
|
||||||
|
Scores map[string]map[string]int `json:"scores"`
|
||||||
|
ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"`
|
||||||
|
PositionBoards map[string]map[string]PositionSlot `json:"positionBoards"`
|
||||||
|
Settings AppSettings `json:"settings"`
|
||||||
|
Derived DerivedState `json:"derived"`
|
||||||
|
CurrentStage *StageSessionState `json:"current_stage"`
|
||||||
|
LastStage *StageSessionState `json:"last_stage"`
|
||||||
|
ServerTime string `json:"serverTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StageSessionState struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
GroupKey string `json:"group"`
|
||||||
|
Round int `json:"round"`
|
||||||
|
StartedAt string `json:"startedAt"`
|
||||||
|
EndedAt string `json:"endedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PositionSlot struct {
|
||||||
|
GroupKey string `json:"groupKey"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppSettings struct {
|
||||||
|
ViewProofInView bool `json:"viewProofInView"`
|
||||||
|
LiveMode LiveModeSettings `json:"liveMode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveModeSettings struct {
|
||||||
|
ActiveView string `json:"activeView"`
|
||||||
|
ShowGroupLive bool `json:"showGroupLive"`
|
||||||
|
GroupDisplayMode string `json:"groupDisplayMode"`
|
||||||
|
GroupFixedCode string `json:"groupFixedCode"`
|
||||||
|
ShowPrelimTie bool `json:"showPrelimTie"`
|
||||||
|
ShowPrelimOverall bool `json:"showPrelimOverall"`
|
||||||
|
ShowFinalGroups bool `json:"showFinalGroups"`
|
||||||
|
FinalDisplayMode string `json:"finalDisplayMode"`
|
||||||
|
FinalFixedGroup int `json:"finalFixedGroup"`
|
||||||
|
ShowFinalTie bool `json:"showFinalTie"`
|
||||||
|
ShowPodium bool `json:"showPodium"`
|
||||||
|
RotationIntervalSec int `json:"rotationIntervalSec"`
|
||||||
|
RotationPlayerCount int `json:"rotationPlayerCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminLoginRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerCreateRequest struct {
|
||||||
|
NameAr string `json:"nameAr"`
|
||||||
|
NameEn string `json:"nameEn"`
|
||||||
|
GroupCode string `json:"groupCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerUpdateRequest struct {
|
||||||
|
NameAr *string `json:"nameAr"`
|
||||||
|
NameEn *string `json:"nameEn"`
|
||||||
|
GroupCode *string `json:"groupCode"`
|
||||||
|
ImageData *string `json:"imageData"`
|
||||||
|
ClearImage bool `json:"clearImage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreUpdateRequest struct {
|
||||||
|
Score int `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreProofUpdateRequest struct {
|
||||||
|
ImageData string `json:"imageData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminSettingsUpdateRequest struct {
|
||||||
|
ViewProofInView *bool `json:"viewProofInView"`
|
||||||
|
LiveMode *LiveModeSettingsUpdateRequest `json:"liveMode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LiveModeSettingsUpdateRequest struct {
|
||||||
|
ActiveView *string `json:"activeView"`
|
||||||
|
ShowGroupLive *bool `json:"showGroupLive"`
|
||||||
|
GroupDisplayMode *string `json:"groupDisplayMode"`
|
||||||
|
GroupFixedCode *string `json:"groupFixedCode"`
|
||||||
|
ShowPrelimTie *bool `json:"showPrelimTie"`
|
||||||
|
ShowPrelimOverall *bool `json:"showPrelimOverall"`
|
||||||
|
ShowFinalGroups *bool `json:"showFinalGroups"`
|
||||||
|
FinalDisplayMode *string `json:"finalDisplayMode"`
|
||||||
|
FinalFixedGroup *int `json:"finalFixedGroup"`
|
||||||
|
ShowFinalTie *bool `json:"showFinalTie"`
|
||||||
|
ShowPodium *bool `json:"showPodium"`
|
||||||
|
RotationIntervalSec *int `json:"rotationIntervalSec"`
|
||||||
|
RotationPlayerCount *int `json:"rotationPlayerCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResetStageRequest struct {
|
||||||
|
ResetProofs bool `json:"resetProofs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoGroupPlayersRequest struct {
|
||||||
|
Groups []string `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PositionBoardUpdateRequest struct {
|
||||||
|
Slots []PositionBoardSlotInput `json:"slots"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PositionBoardSlotInput struct {
|
||||||
|
PlayerID int `json:"playerId"`
|
||||||
|
GroupKey string `json:"groupKey"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScoreAdviceResponse struct {
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
PlayerID int `json:"playerId"`
|
||||||
|
AdvisedScore int `json:"advisedScore"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
GeneratedAt string `json:"generatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StageControlStartRequest struct {
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
GroupKey string `json:"group"`
|
||||||
|
Round int `json:"round"`
|
||||||
|
}
|
||||||
1410
backend/openapi/openapi.json
Normal file
65
backend/routes.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerAPIRoutes(e *echo.Echo, app *App) {
|
||||||
|
api := e.Group("/api")
|
||||||
|
api.GET("/docs", app.handleOpenAPIDocs)
|
||||||
|
api.GET("/docs/", app.handleOpenAPIDocs)
|
||||||
|
api.GET("/openapi.json", app.handleOpenAPISpec)
|
||||||
|
|
||||||
|
api.GET("/health", app.handleHealth)
|
||||||
|
api.GET("/state", app.handleGetState)
|
||||||
|
api.GET("/events", app.handleEvents)
|
||||||
|
|
||||||
|
api.POST("/admin/login", app.handleAdminLogin)
|
||||||
|
api.POST("/admin/logout", app.handleAdminLogout, app.adminOnly)
|
||||||
|
api.GET("/admin/state", app.handleGetState, app.adminOnly)
|
||||||
|
api.GET("/admin/stage/history", app.handleGetStageHistory, app.adminOnly)
|
||||||
|
api.PUT("/admin/settings", app.handleUpdateAdminSettings, app.adminOnly)
|
||||||
|
api.POST("/admin/stage/start", app.handleStartCurrentStage, app.adminOnly)
|
||||||
|
api.POST("/admin/stage/end", app.handleEndCurrentStage, app.adminOnly)
|
||||||
|
api.POST("/admin/players", app.handleCreatePlayer, app.adminOnly)
|
||||||
|
api.POST("/admin/players/auto-group", app.handleAutoGroupPlayers, app.adminOnly)
|
||||||
|
api.PUT("/admin/positions/:board", app.handleUpdatePositionBoard, app.adminOnly)
|
||||||
|
api.PUT("/admin/players/:id", app.handleUpdatePlayer, app.adminOnly)
|
||||||
|
api.DELETE("/admin/players/:id", app.handleDeletePlayer, app.adminOnly)
|
||||||
|
api.PUT("/admin/scores/:stage/:id", app.handleUpdateScore, app.adminOnly)
|
||||||
|
api.PUT("/admin/scores/:stage/:id/proof", app.handleUpdateScoreProof, app.adminOnly)
|
||||||
|
api.DELETE("/admin/scores/:stage/:id/proof", app.handleDeleteScoreProof, app.adminOnly)
|
||||||
|
api.POST("/admin/scores/:stage/:id/advice", app.handleScoreAdvice, app.adminOnly)
|
||||||
|
api.POST("/admin/scores/:stage/reset", app.handleResetStageScores, app.adminOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerWebRoutes(e *echo.Echo, cfg Config) {
|
||||||
|
e.GET("/*", func(c echo.Context) error {
|
||||||
|
requestPath := strings.TrimPrefix(c.Param("*"), "/")
|
||||||
|
if strings.HasPrefix(requestPath, "api") {
|
||||||
|
return echo.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestPath == "" {
|
||||||
|
requestPath = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
clean := strings.TrimPrefix(filepath.Clean("/"+requestPath), "/")
|
||||||
|
assetPath := filepath.Join(cfg.WebDir, clean)
|
||||||
|
if stat, err := os.Stat(assetPath); err == nil && !stat.IsDir() {
|
||||||
|
return c.File(assetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
indexPath := filepath.Join(cfg.WebDir, "index.html")
|
||||||
|
if stat, err := os.Stat(indexPath); err == nil && !stat.IsDir() {
|
||||||
|
return c.File(indexPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.String(http.StatusNotFound, "frontend build not found. run make build")
|
||||||
|
})
|
||||||
|
}
|
||||||
366
backend/settings.go
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
settingViewProofInView = "view_proof_in_view"
|
||||||
|
settingLiveActiveView = "live_active_view"
|
||||||
|
settingLiveShowGroupLive = "live_show_group_live"
|
||||||
|
settingLiveGroupDisplayMode = "live_group_display_mode"
|
||||||
|
settingLiveGroupFixedCode = "live_group_fixed_code"
|
||||||
|
settingLiveShowPrelimTie = "live_show_prelim_tie"
|
||||||
|
settingLiveShowPrelimOverall = "live_show_prelim_overall"
|
||||||
|
settingLiveShowFinalGroups = "live_show_final_groups"
|
||||||
|
settingLiveFinalDisplayMode = "live_final_display_mode"
|
||||||
|
settingLiveFinalFixedGroup = "live_final_fixed_group"
|
||||||
|
settingLiveShowFinalTie = "live_show_final_tie"
|
||||||
|
settingLiveShowPodium = "live_show_podium"
|
||||||
|
settingLiveRotationInterval = "live_rotation_interval_sec"
|
||||||
|
settingLiveRotationPlayers = "live_rotation_player_count"
|
||||||
|
)
|
||||||
|
|
||||||
|
func defaultLiveModeSettings() LiveModeSettings {
|
||||||
|
return LiveModeSettings{
|
||||||
|
ActiveView: "group_live",
|
||||||
|
ShowGroupLive: true,
|
||||||
|
GroupDisplayMode: "rotate",
|
||||||
|
GroupFixedCode: "",
|
||||||
|
ShowPrelimTie: true,
|
||||||
|
ShowPrelimOverall: true,
|
||||||
|
ShowFinalGroups: true,
|
||||||
|
FinalDisplayMode: "rotate",
|
||||||
|
FinalFixedGroup: 1,
|
||||||
|
ShowFinalTie: true,
|
||||||
|
ShowPodium: true,
|
||||||
|
RotationIntervalSec: 5,
|
||||||
|
RotationPlayerCount: 12,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readSettings() (AppSettings, error) {
|
||||||
|
live := defaultLiveModeSettings()
|
||||||
|
|
||||||
|
viewProofRaw, err := a.readSettingValue(settingViewProofInView)
|
||||||
|
if err != nil {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingViewProofInView, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveShowGroupLive); err == nil {
|
||||||
|
live.ShowGroupLive = settingBool(raw)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowGroupLive, err)
|
||||||
|
}
|
||||||
|
if raw, err := a.readSettingValue(settingLiveActiveView); err == nil {
|
||||||
|
live.ActiveView = normalizeLiveActiveView(raw, live.ActiveView)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveActiveView, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveGroupDisplayMode); err == nil {
|
||||||
|
live.GroupDisplayMode = normalizeLiveDisplayMode(raw, live.GroupDisplayMode)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveGroupDisplayMode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveGroupFixedCode); err == nil {
|
||||||
|
live.GroupFixedCode = strings.ToUpper(strings.TrimSpace(raw))
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveGroupFixedCode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveShowPrelimTie); err == nil {
|
||||||
|
live.ShowPrelimTie = settingBool(raw)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowPrelimTie, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveShowPrelimOverall); err == nil {
|
||||||
|
live.ShowPrelimOverall = settingBool(raw)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowPrelimOverall, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveShowFinalGroups); err == nil {
|
||||||
|
live.ShowFinalGroups = settingBool(raw)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowFinalGroups, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveFinalDisplayMode); err == nil {
|
||||||
|
live.FinalDisplayMode = normalizeLiveDisplayMode(raw, live.FinalDisplayMode)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveFinalDisplayMode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveFinalFixedGroup); err == nil {
|
||||||
|
live.FinalFixedGroup = normalizeLiveFinalFixedGroup(raw, live.FinalFixedGroup)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveFinalFixedGroup, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveShowFinalTie); err == nil {
|
||||||
|
live.ShowFinalTie = settingBool(raw)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowFinalTie, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveShowPodium); err == nil {
|
||||||
|
live.ShowPodium = settingBool(raw)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveShowPodium, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw, err := a.readSettingValue(settingLiveRotationInterval); err == nil {
|
||||||
|
live.RotationIntervalSec = normalizeRotationInterval(raw, live.RotationIntervalSec)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveRotationInterval, err)
|
||||||
|
}
|
||||||
|
if raw, err := a.readSettingValue(settingLiveRotationPlayers); err == nil {
|
||||||
|
live.RotationPlayerCount = normalizeRotationPlayerCount(raw, live.RotationPlayerCount)
|
||||||
|
} else {
|
||||||
|
return AppSettings{}, fmt.Errorf("read app setting %s: %w", settingLiveRotationPlayers, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppSettings{
|
||||||
|
ViewProofInView: settingBool(viewProofRaw),
|
||||||
|
LiveMode: live,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateSettings(req AdminSettingsUpdateRequest) error {
|
||||||
|
tx, err := a.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin settings transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
hasUpdate := false
|
||||||
|
|
||||||
|
if req.ViewProofInView != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingViewProofInView, settingString(*req.ViewProofInView)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.LiveMode != nil {
|
||||||
|
patch := req.LiveMode
|
||||||
|
if patch.ActiveView != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := normalizeLiveActiveView(*patch.ActiveView, "group_live")
|
||||||
|
if err := upsertSetting(tx, settingLiveActiveView, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.ShowGroupLive != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingLiveShowGroupLive, settingString(*patch.ShowGroupLive)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.GroupDisplayMode != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := normalizeLiveDisplayMode(*patch.GroupDisplayMode, "rotate")
|
||||||
|
if err := upsertSetting(tx, settingLiveGroupDisplayMode, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.GroupFixedCode != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := strings.ToUpper(strings.TrimSpace(*patch.GroupFixedCode))
|
||||||
|
if err := upsertSetting(tx, settingLiveGroupFixedCode, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.ShowPrelimTie != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingLiveShowPrelimTie, settingString(*patch.ShowPrelimTie)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.ShowPrelimOverall != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingLiveShowPrelimOverall, settingString(*patch.ShowPrelimOverall)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.ShowFinalGroups != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingLiveShowFinalGroups, settingString(*patch.ShowFinalGroups)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.FinalDisplayMode != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := normalizeLiveDisplayMode(*patch.FinalDisplayMode, "rotate")
|
||||||
|
if err := upsertSetting(tx, settingLiveFinalDisplayMode, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.FinalFixedGroup != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := normalizeLiveFinalFixedGroupInt(*patch.FinalFixedGroup)
|
||||||
|
if err := upsertSetting(tx, settingLiveFinalFixedGroup, strconv.Itoa(value)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.ShowFinalTie != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingLiveShowFinalTie, settingString(*patch.ShowFinalTie)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.ShowPodium != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
if err := upsertSetting(tx, settingLiveShowPodium, settingString(*patch.ShowPodium)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.RotationIntervalSec != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := normalizeRotationIntervalInt(*patch.RotationIntervalSec)
|
||||||
|
if err := upsertSetting(tx, settingLiveRotationInterval, strconv.Itoa(value)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.RotationPlayerCount != nil {
|
||||||
|
hasUpdate = true
|
||||||
|
value := normalizeRotationPlayerCountInt(*patch.RotationPlayerCount)
|
||||||
|
if err := upsertSetting(tx, settingLiveRotationPlayers, strconv.Itoa(value)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasUpdate {
|
||||||
|
return fmt.Errorf("no settings to update")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit settings transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertSetting(tx *sql.Tx, key string, value string) error {
|
||||||
|
_, err := tx.Exec(`
|
||||||
|
INSERT INTO app_settings(key, value, updated_at)
|
||||||
|
VALUES(?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, key, value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update app setting %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readSettingValue(key string) (string, error) {
|
||||||
|
var raw string
|
||||||
|
err := a.db.QueryRow(`SELECT value FROM app_settings WHERE key = ?`, key).Scan(&raw)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingBool(value string) bool {
|
||||||
|
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingString(value bool) string {
|
||||||
|
if value {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLiveDisplayMode(value string, fallback string) string {
|
||||||
|
normalized := strings.TrimSpace(strings.ToLower(value))
|
||||||
|
if normalized == "fixed" {
|
||||||
|
return "fixed"
|
||||||
|
}
|
||||||
|
if normalized == "rotate" {
|
||||||
|
return "rotate"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(strings.ToLower(fallback)) == "fixed" {
|
||||||
|
return "fixed"
|
||||||
|
}
|
||||||
|
return "rotate"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLiveFinalFixedGroup(value string, fallback int) int {
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||||
|
if err != nil {
|
||||||
|
return normalizeLiveFinalFixedGroupInt(fallback)
|
||||||
|
}
|
||||||
|
return normalizeLiveFinalFixedGroupInt(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLiveFinalFixedGroupInt(value int) int {
|
||||||
|
if value == 2 {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRotationInterval(raw string, fallback int) int {
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return normalizeRotationIntervalInt(fallback)
|
||||||
|
}
|
||||||
|
return normalizeRotationIntervalInt(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRotationIntervalInt(value int) int {
|
||||||
|
if value < 3 {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
if value > 30 {
|
||||||
|
return 30
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRotationPlayerCount(raw string, fallback int) int {
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return normalizeRotationPlayerCountInt(fallback)
|
||||||
|
}
|
||||||
|
return normalizeRotationPlayerCountInt(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRotationPlayerCountInt(value int) int {
|
||||||
|
if value < 3 {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
if value > 40 {
|
||||||
|
return 40
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLiveActiveView(value string, fallback string) string {
|
||||||
|
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||||
|
case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "final_overall", "podium":
|
||||||
|
return strings.TrimSpace(strings.ToLower(value))
|
||||||
|
default:
|
||||||
|
if strings.TrimSpace(strings.ToLower(fallback)) != "" {
|
||||||
|
return strings.TrimSpace(strings.ToLower(fallback))
|
||||||
|
}
|
||||||
|
return "group_live"
|
||||||
|
}
|
||||||
|
}
|
||||||
172
backend/stage_sessions.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
stagePhasePreliminary = "preliminary"
|
||||||
|
stagePhasePrelimTie = "prelim_tiebreak"
|
||||||
|
stagePhaseFinal = "final"
|
||||||
|
stagePhaseFinalTie = "final_tiebreak"
|
||||||
|
)
|
||||||
|
|
||||||
|
var allowedStagePhases = map[string]bool{
|
||||||
|
stagePhasePreliminary: true,
|
||||||
|
stagePhasePrelimTie: true,
|
||||||
|
stagePhaseFinal: true,
|
||||||
|
stagePhaseFinalTie: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStagePhase(raw string) (string, bool) {
|
||||||
|
phase := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
if !allowedStagePhases[phase] {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return phase, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStageGroup(phase string, raw string) (string, bool) {
|
||||||
|
group := strings.TrimSpace(raw)
|
||||||
|
switch phase {
|
||||||
|
case stagePhasePreliminary:
|
||||||
|
if strings.EqualFold(group, positionBoardUnassigned) {
|
||||||
|
return positionBoardUnassigned, true
|
||||||
|
}
|
||||||
|
group = normalizePreliminaryGroupKey(group)
|
||||||
|
if group == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return group, true
|
||||||
|
case stagePhaseFinal:
|
||||||
|
parsed, err := strconv.Atoi(group)
|
||||||
|
if err != nil || (parsed != 1 && parsed != 2) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strconv.Itoa(parsed), true
|
||||||
|
case stagePhasePrelimTie, stagePhaseFinalTie:
|
||||||
|
parsed, err := strconv.Atoi(group)
|
||||||
|
if err != nil || parsed <= 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strconv.Itoa(parsed), true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStageRound(phase string, round int) int {
|
||||||
|
switch phase {
|
||||||
|
case stagePhasePreliminary:
|
||||||
|
if round < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if round > 3 {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return round
|
||||||
|
case stagePhaseFinal:
|
||||||
|
if round < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if round > 2 {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return round
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTimestamp(raw string) string {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
|
||||||
|
return parsed.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil {
|
||||||
|
return parsed.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanStageSession(scanner interface{ Scan(dest ...any) error }) (*StageSessionState, error) {
|
||||||
|
var item StageSessionState
|
||||||
|
var ended sql.NullString
|
||||||
|
if err := scanner.Scan(&item.ID, &item.Phase, &item.GroupKey, &item.Round, &item.StartedAt, &ended); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.Phase = strings.ToLower(strings.TrimSpace(item.Phase))
|
||||||
|
item.GroupKey = strings.TrimSpace(item.GroupKey)
|
||||||
|
item.StartedAt = normalizeTimestamp(item.StartedAt)
|
||||||
|
if ended.Valid {
|
||||||
|
item.EndedAt = normalizeTimestamp(ended.String)
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readCurrentStage() (*StageSessionState, error) {
|
||||||
|
row := a.db.QueryRow(`
|
||||||
|
SELECT id, phase, group_key, round, started_at, ended_at
|
||||||
|
FROM stage_sessions
|
||||||
|
WHERE ended_at IS NULL
|
||||||
|
ORDER BY started_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`)
|
||||||
|
item, err := scanStageSession(row)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read current stage: %w", err)
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readLastStage() (*StageSessionState, error) {
|
||||||
|
row := a.db.QueryRow(`
|
||||||
|
SELECT id, phase, group_key, round, started_at, ended_at
|
||||||
|
FROM stage_sessions
|
||||||
|
ORDER BY started_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`)
|
||||||
|
item, err := scanStageSession(row)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read last stage: %w", err)
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readStageHistory() ([]StageSessionState, error) {
|
||||||
|
rows, err := a.db.Query(`
|
||||||
|
SELECT id, phase, group_key, round, started_at, ended_at
|
||||||
|
FROM stage_sessions
|
||||||
|
ORDER BY started_at DESC, id DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query stage history: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]StageSessionState, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
item, scanErr := scanStageSession(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, fmt.Errorf("scan stage history: %w", scanErr)
|
||||||
|
}
|
||||||
|
items = append(items, *item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate stage history: %w", err)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
728
backend/state.go
Normal file
@@ -0,0 +1,728 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
positionBoardPreliminary = "preliminary"
|
||||||
|
positionBoardFinal = "final"
|
||||||
|
positionBoardPrelimTie = "prelim_tiebreak"
|
||||||
|
positionBoardFinalTie = "final_tiebreak"
|
||||||
|
positionBoardUnassigned = "UNASSIGNED"
|
||||||
|
)
|
||||||
|
|
||||||
|
var allowedPositionBoards = map[string]bool{
|
||||||
|
positionBoardPreliminary: true,
|
||||||
|
positionBoardFinal: true,
|
||||||
|
positionBoardPrelimTie: true,
|
||||||
|
positionBoardFinalTie: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
|
||||||
|
players, err := a.readPlayers()
|
||||||
|
if err != nil {
|
||||||
|
return StateResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.ensureScoreRows(players); err != nil {
|
||||||
|
return StateResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
settings, err := a.readSettings()
|
||||||
|
if err != nil {
|
||||||
|
return StateResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scoreMap, err := a.readScores(players)
|
||||||
|
if err != nil {
|
||||||
|
return StateResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
includeScoreProofs := includeAllProofs || settings.ViewProofInView
|
||||||
|
var scoreProofs map[string]map[int]string
|
||||||
|
if includeScoreProofs {
|
||||||
|
scoreProofs, err = a.readScoreProofs(players)
|
||||||
|
if err != nil {
|
||||||
|
return StateResponse{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
derived := computeDerived(players, scoreMap)
|
||||||
|
positionBoards, err := a.readPositionBoards(players, derived)
|
||||||
|
if err != nil {
|
||||||
|
return StateResponse{}, err
|
||||||
|
}
|
||||||
|
currentStage, err := a.readCurrentStage()
|
||||||
|
if err != nil {
|
||||||
|
return StateResponse{}, err
|
||||||
|
}
|
||||||
|
lastStage, err := a.readLastStage()
|
||||||
|
if err != nil {
|
||||||
|
return StateResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
response := StateResponse{
|
||||||
|
Competition: CompetitionMeta{
|
||||||
|
TitleAr: "بطولة دويتوايلر للرماية",
|
||||||
|
TitleEn: "Datwyler Shooting Event",
|
||||||
|
},
|
||||||
|
Players: players,
|
||||||
|
Scores: scoreMapToJSON(scoreMap),
|
||||||
|
PositionBoards: positionBoardMapToJSON(positionBoards),
|
||||||
|
Settings: settings,
|
||||||
|
Derived: derived,
|
||||||
|
CurrentStage: currentStage,
|
||||||
|
LastStage: lastStage,
|
||||||
|
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if includeScoreProofs {
|
||||||
|
response.ScoreProofs = scoreProofMapToJSON(scoreProofs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readPlayers() ([]Player, error) {
|
||||||
|
rows, err := a.db.Query(`
|
||||||
|
SELECT id, name_ar, name_en, group_code, image_data
|
||||||
|
FROM players
|
||||||
|
ORDER BY id ASC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query players: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
players := []Player{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p Player
|
||||||
|
if err := rows.Scan(&p.ID, &p.NameAr, &p.NameEn, &p.GroupCode, &p.ImageData); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan player: %w", err)
|
||||||
|
}
|
||||||
|
players = append(players, p)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate players: %w", err)
|
||||||
|
}
|
||||||
|
return players, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readScoreProofs(players []Player) (map[string]map[int]string, error) {
|
||||||
|
proofs := map[string]map[int]string{}
|
||||||
|
for _, stage := range scoreStages {
|
||||||
|
proofs[stage] = map[int]string{}
|
||||||
|
}
|
||||||
|
for _, p := range players {
|
||||||
|
for _, stage := range scoreStages {
|
||||||
|
proofs[stage][p.ID] = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := a.db.Query(`SELECT stage, player_id, image_data FROM score_attachments`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query score attachments: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var stage string
|
||||||
|
var playerID int
|
||||||
|
var imageData string
|
||||||
|
if err := rows.Scan(&stage, &playerID, &imageData); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan score attachment: %w", err)
|
||||||
|
}
|
||||||
|
stage = strings.ToLower(strings.TrimSpace(stage))
|
||||||
|
stageMap, ok := proofs[stage]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stageMap[playerID] = imageData
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate score attachments: %w", err)
|
||||||
|
}
|
||||||
|
return proofs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreProofMapToJSON(proofMap map[string]map[int]string) map[string]map[string]string {
|
||||||
|
out := map[string]map[string]string{}
|
||||||
|
for stage, stageMap := range proofMap {
|
||||||
|
out[stage] = map[string]string{}
|
||||||
|
for playerID, imageData := range stageMap {
|
||||||
|
if strings.TrimSpace(imageData) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[stage][strconv.Itoa(playerID)] = imageData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func positionBoardMapToJSON(positionMap map[string]map[int]PositionSlot) map[string]map[string]PositionSlot {
|
||||||
|
out := map[string]map[string]PositionSlot{}
|
||||||
|
for board := range allowedPositionBoards {
|
||||||
|
out[board] = map[string]PositionSlot{}
|
||||||
|
}
|
||||||
|
for board, boardMap := range positionMap {
|
||||||
|
if _, ok := out[board]; !ok {
|
||||||
|
out[board] = map[string]PositionSlot{}
|
||||||
|
}
|
||||||
|
for playerID, slot := range boardMap {
|
||||||
|
out[board][strconv.Itoa(playerID)] = slot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readPositionBoards(players []Player, derived DerivedState) (map[string]map[int]PositionSlot, error) {
|
||||||
|
defaults := defaultPositionBoards(players, derived)
|
||||||
|
merged := map[string]map[int]PositionSlot{}
|
||||||
|
for board, boardMap := range defaults {
|
||||||
|
merged[board] = map[int]PositionSlot{}
|
||||||
|
for playerID, slot := range boardMap {
|
||||||
|
merged[board][playerID] = slot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := a.db.Query(`SELECT board, player_id, group_key, position FROM player_positions`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query player positions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var boardRaw string
|
||||||
|
var playerID int
|
||||||
|
var groupKey string
|
||||||
|
var position int
|
||||||
|
if err := rows.Scan(&boardRaw, &playerID, &groupKey, &position); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan player position: %w", err)
|
||||||
|
}
|
||||||
|
board, ok := normalizePositionBoard(boardRaw)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
boardMap := merged[board]
|
||||||
|
if boardMap == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defaultSlot, exists := boardMap[playerID]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pos := normalizePositionValue(position, defaultSlot.Position)
|
||||||
|
group := normalizePositionGroupKey(board, groupKey, defaultSlot.GroupKey)
|
||||||
|
boardMap[playerID] = PositionSlot{
|
||||||
|
GroupKey: group,
|
||||||
|
Position: pos,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate player positions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPositionBoards(players []Player, derived DerivedState) map[string]map[int]PositionSlot {
|
||||||
|
out := map[string]map[int]PositionSlot{
|
||||||
|
positionBoardPreliminary: {},
|
||||||
|
positionBoardFinal: {},
|
||||||
|
positionBoardPrelimTie: {},
|
||||||
|
positionBoardFinalTie: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
preRows := make([]Player, 0, len(players))
|
||||||
|
preRows = append(preRows, players...)
|
||||||
|
sort.SliceStable(preRows, func(i, j int) bool {
|
||||||
|
ga := normalizePreliminaryGroupKey(preRows[i].GroupCode)
|
||||||
|
gb := normalizePreliminaryGroupKey(preRows[j].GroupCode)
|
||||||
|
if ga != gb {
|
||||||
|
return ga < gb
|
||||||
|
}
|
||||||
|
return preRows[i].ID < preRows[j].ID
|
||||||
|
})
|
||||||
|
preGroupCounts := map[string]int{}
|
||||||
|
for _, player := range preRows {
|
||||||
|
group := normalizePreliminaryGroupKey(player.GroupCode)
|
||||||
|
preGroupCounts[group]++
|
||||||
|
out[positionBoardPreliminary][player.ID] = PositionSlot{
|
||||||
|
GroupKey: group,
|
||||||
|
Position: preGroupCounts[group],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalGroupCounts := map[string]int{}
|
||||||
|
for _, row := range derived.FinalGroups.Group1 {
|
||||||
|
group := "1"
|
||||||
|
finalGroupCounts[group]++
|
||||||
|
out[positionBoardFinal][row.PlayerID] = PositionSlot{GroupKey: group, Position: finalGroupCounts[group]}
|
||||||
|
}
|
||||||
|
for _, row := range derived.FinalGroups.Group2 {
|
||||||
|
group := "2"
|
||||||
|
finalGroupCounts[group]++
|
||||||
|
out[positionBoardFinal][row.PlayerID] = PositionSlot{GroupKey: group, Position: finalGroupCounts[group]}
|
||||||
|
}
|
||||||
|
|
||||||
|
preRowsByID := map[int]RankingRow{}
|
||||||
|
for _, row := range derived.PreliminaryRanking.Rows {
|
||||||
|
preRowsByID[row.PlayerID] = row
|
||||||
|
}
|
||||||
|
preTieRows := make([]RankingRow, 0, len(derived.PreliminaryRanking.TieBreak.PlayerIDs))
|
||||||
|
for _, id := range derived.PreliminaryRanking.TieBreak.PlayerIDs {
|
||||||
|
row, ok := preRowsByID[id]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
preTieRows = append(preTieRows, row)
|
||||||
|
}
|
||||||
|
sort.SliceStable(preTieRows, func(i, j int) bool {
|
||||||
|
if preTieRows[i].TieBreak != preTieRows[j].TieBreak {
|
||||||
|
return preTieRows[i].TieBreak > preTieRows[j].TieBreak
|
||||||
|
}
|
||||||
|
if preTieRows[i].Score != preTieRows[j].Score {
|
||||||
|
return preTieRows[i].Score > preTieRows[j].Score
|
||||||
|
}
|
||||||
|
return preTieRows[i].PlayerID < preTieRows[j].PlayerID
|
||||||
|
})
|
||||||
|
for idx, row := range preTieRows {
|
||||||
|
groupKey := strconv.Itoa((idx / 6) + 1)
|
||||||
|
position := (idx % 6) + 1
|
||||||
|
out[positionBoardPrelimTie][row.PlayerID] = PositionSlot{GroupKey: groupKey, Position: position}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalRowsByID := map[int]RankingRow{}
|
||||||
|
for _, row := range derived.FinalRanking.Rows {
|
||||||
|
finalRowsByID[row.PlayerID] = row
|
||||||
|
}
|
||||||
|
finalTieRows := make([]RankingRow, 0, len(derived.FinalRanking.TieBreak.PlayerIDs))
|
||||||
|
for _, id := range derived.FinalRanking.TieBreak.PlayerIDs {
|
||||||
|
row, ok := finalRowsByID[id]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
finalTieRows = append(finalTieRows, row)
|
||||||
|
}
|
||||||
|
sort.SliceStable(finalTieRows, func(i, j int) bool {
|
||||||
|
if finalTieRows[i].TieBreak != finalTieRows[j].TieBreak {
|
||||||
|
return finalTieRows[i].TieBreak > finalTieRows[j].TieBreak
|
||||||
|
}
|
||||||
|
if finalTieRows[i].Score != finalTieRows[j].Score {
|
||||||
|
return finalTieRows[i].Score > finalTieRows[j].Score
|
||||||
|
}
|
||||||
|
return finalTieRows[i].PlayerID < finalTieRows[j].PlayerID
|
||||||
|
})
|
||||||
|
for idx, row := range finalTieRows {
|
||||||
|
groupKey := strconv.Itoa((idx / 6) + 1)
|
||||||
|
position := (idx % 6) + 1
|
||||||
|
out[positionBoardFinalTie][row.PlayerID] = PositionSlot{GroupKey: groupKey, Position: position}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePositionBoard(board string) (string, bool) {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(board))
|
||||||
|
if !allowedPositionBoards[normalized] {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return normalized, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePreliminaryGroupKey(groupCode string) string {
|
||||||
|
normalized := strings.ToUpper(strings.TrimSpace(groupCode))
|
||||||
|
if normalized == "" {
|
||||||
|
return positionBoardUnassigned
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePositionGroupKey(board, groupKey, fallback string) string {
|
||||||
|
if board == positionBoardPreliminary {
|
||||||
|
normalized := normalizePreliminaryGroupKey(groupKey)
|
||||||
|
if normalized == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
normalized := strings.TrimSpace(groupKey)
|
||||||
|
if normalized == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePositionValue(position int, fallback int) int {
|
||||||
|
if position > 0 {
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
if fallback > 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureScoreRows(players []Player) error {
|
||||||
|
if len(players) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tx, err := a.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin score row ensure tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for _, p := range players {
|
||||||
|
for _, stage := range scoreStages {
|
||||||
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO scores(stage, player_id, score) VALUES(?, ?, 0)`, stage, p.ID); err != nil {
|
||||||
|
return fmt.Errorf("ensure score row (%s,%d): %w", stage, p.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit score row ensure tx: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) readScores(players []Player) (map[string]map[int]int, error) {
|
||||||
|
scores := map[string]map[int]int{}
|
||||||
|
for _, stage := range scoreStages {
|
||||||
|
scores[stage] = map[int]int{}
|
||||||
|
}
|
||||||
|
for _, p := range players {
|
||||||
|
for _, stage := range scoreStages {
|
||||||
|
scores[stage][p.ID] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := a.db.Query(`SELECT stage, player_id, score FROM scores`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query scores: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var stage string
|
||||||
|
var playerID int
|
||||||
|
var score int
|
||||||
|
if err := rows.Scan(&stage, &playerID, &score); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan score: %w", err)
|
||||||
|
}
|
||||||
|
stage = strings.ToLower(stage)
|
||||||
|
if _, ok := scores[stage]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scores[stage][playerID] = score
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate scores: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scores, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreMapToJSON(scoreMap map[string]map[int]int) map[string]map[string]int {
|
||||||
|
out := map[string]map[string]int{}
|
||||||
|
for stage, stageMap := range scoreMap {
|
||||||
|
out[stage] = map[string]int{}
|
||||||
|
for playerID, value := range stageMap {
|
||||||
|
out[stage][strconv.Itoa(playerID)] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeDerived(players []Player, scores map[string]map[int]int) DerivedState {
|
||||||
|
playerByID := map[int]Player{}
|
||||||
|
for _, p := range players {
|
||||||
|
playerByID[p.ID] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
preRows := []RankingRow{}
|
||||||
|
for _, p := range players {
|
||||||
|
preRows = append(preRows, RankingRow{
|
||||||
|
PlayerID: p.ID,
|
||||||
|
NameAr: p.NameAr,
|
||||||
|
NameEn: p.NameEn,
|
||||||
|
GroupCode: p.GroupCode,
|
||||||
|
ImageData: p.ImageData,
|
||||||
|
Score: scoreTotal(scores, p.ID, preliminaryRoundStages),
|
||||||
|
TieBreak: scores["prelim_tiebreak"][p.ID],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(preRows, func(i, j int) bool {
|
||||||
|
if preRows[i].Score != preRows[j].Score {
|
||||||
|
return preRows[i].Score > preRows[j].Score
|
||||||
|
}
|
||||||
|
return preRows[i].PlayerID < preRows[j].PlayerID
|
||||||
|
})
|
||||||
|
assignDenseRankByScore(preRows)
|
||||||
|
|
||||||
|
preTie := TieBreakInfo{Required: false, Resolved: true, Slots: 0, PlayerIDs: []int{}}
|
||||||
|
finalists := []RankingRow{}
|
||||||
|
|
||||||
|
if len(preRows) <= 12 {
|
||||||
|
for i := range preRows {
|
||||||
|
row := preRows[i]
|
||||||
|
row.Seed = i + 1
|
||||||
|
finalists = append(finalists, row)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cutoff := preRows[11].Score
|
||||||
|
above := []RankingRow{}
|
||||||
|
atCutoff := []RankingRow{}
|
||||||
|
for _, row := range preRows {
|
||||||
|
if row.Score > cutoff {
|
||||||
|
above = append(above, row)
|
||||||
|
} else if row.Score == cutoff {
|
||||||
|
atCutoff = append(atCutoff, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slots := 12 - len(above)
|
||||||
|
if slots < 0 {
|
||||||
|
slots = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(atCutoff) <= slots {
|
||||||
|
finalists = append(finalists, above...)
|
||||||
|
finalists = append(finalists, atCutoff...)
|
||||||
|
} else {
|
||||||
|
preTie.Required = true
|
||||||
|
preTie.Slots = slots
|
||||||
|
preTie.Resolved = true
|
||||||
|
for _, row := range atCutoff {
|
||||||
|
preTie.PlayerIDs = append(preTie.PlayerIDs, row.PlayerID)
|
||||||
|
}
|
||||||
|
sort.Ints(preTie.PlayerIDs)
|
||||||
|
|
||||||
|
sort.SliceStable(atCutoff, func(i, j int) bool {
|
||||||
|
if atCutoff[i].TieBreak != atCutoff[j].TieBreak {
|
||||||
|
return atCutoff[i].TieBreak > atCutoff[j].TieBreak
|
||||||
|
}
|
||||||
|
return atCutoff[i].PlayerID < atCutoff[j].PlayerID
|
||||||
|
})
|
||||||
|
|
||||||
|
if slots > 0 {
|
||||||
|
boundary := atCutoff[slots-1].TieBreak
|
||||||
|
greater := 0
|
||||||
|
equal := 0
|
||||||
|
for _, row := range atCutoff {
|
||||||
|
if row.TieBreak > boundary {
|
||||||
|
greater++
|
||||||
|
} else if row.TieBreak == boundary {
|
||||||
|
equal++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if greater < slots && greater+equal > slots {
|
||||||
|
preTie.Resolved = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalists = append(finalists, above...)
|
||||||
|
if slots > len(atCutoff) {
|
||||||
|
slots = len(atCutoff)
|
||||||
|
}
|
||||||
|
finalists = append(finalists, atCutoff[:slots]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(finalists, func(i, j int) bool {
|
||||||
|
if finalists[i].Score != finalists[j].Score {
|
||||||
|
return finalists[i].Score > finalists[j].Score
|
||||||
|
}
|
||||||
|
if preTie.Required {
|
||||||
|
if finalists[i].TieBreak != finalists[j].TieBreak {
|
||||||
|
return finalists[i].TieBreak > finalists[j].TieBreak
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finalists[i].PlayerID < finalists[j].PlayerID
|
||||||
|
})
|
||||||
|
assignDenseRankBy(finalists, func(a, b RankingRow) bool {
|
||||||
|
if a.Score != b.Score {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if preTie.Required {
|
||||||
|
return a.TieBreak == b.TieBreak
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range finalists {
|
||||||
|
finalists[i].Seed = i + 1
|
||||||
|
if i < 6 {
|
||||||
|
finalists[i].FinalGroup = 1
|
||||||
|
} else {
|
||||||
|
finalists[i].FinalGroup = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalGroup1 := []RankingRow{}
|
||||||
|
finalGroup2 := []RankingRow{}
|
||||||
|
for _, row := range finalists {
|
||||||
|
if row.FinalGroup == 1 {
|
||||||
|
finalGroup1 = append(finalGroup1, row)
|
||||||
|
} else {
|
||||||
|
finalGroup2 = append(finalGroup2, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finalRows := []RankingRow{}
|
||||||
|
for _, finalist := range finalists {
|
||||||
|
p := playerByID[finalist.PlayerID]
|
||||||
|
finalRows = append(finalRows, RankingRow{
|
||||||
|
PlayerID: finalist.PlayerID,
|
||||||
|
NameAr: p.NameAr,
|
||||||
|
NameEn: p.NameEn,
|
||||||
|
GroupCode: p.GroupCode,
|
||||||
|
ImageData: p.ImageData,
|
||||||
|
Score: scoreTotal(scores, finalist.PlayerID, finalRoundStages),
|
||||||
|
TieBreak: scores["final_tiebreak"][finalist.PlayerID],
|
||||||
|
Seed: finalist.Seed,
|
||||||
|
FinalGroup: finalist.FinalGroup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(finalRows, func(i, j int) bool {
|
||||||
|
if finalRows[i].Score != finalRows[j].Score {
|
||||||
|
return finalRows[i].Score > finalRows[j].Score
|
||||||
|
}
|
||||||
|
return finalRows[i].Seed < finalRows[j].Seed
|
||||||
|
})
|
||||||
|
|
||||||
|
finalTie := TieBreakInfo{Required: false, Resolved: true, Slots: 0, PlayerIDs: []int{}}
|
||||||
|
tiedTop := map[int]bool{}
|
||||||
|
if len(finalRows) > 0 {
|
||||||
|
i := 0
|
||||||
|
for i < len(finalRows) {
|
||||||
|
j := i + 1
|
||||||
|
for j < len(finalRows) && finalRows[j].Score == finalRows[i].Score {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j-i > 1 && finalRows[i].Score > 0 {
|
||||||
|
startPos := i + 1
|
||||||
|
endPos := j
|
||||||
|
if startPos <= 3 || endPos <= 3 || (startPos < 3 && endPos > 3) {
|
||||||
|
for k := i; k < j; k++ {
|
||||||
|
tiedTop[finalRows[k].PlayerID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = j
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tiedTop) > 0 {
|
||||||
|
finalTie.Required = true
|
||||||
|
for id := range tiedTop {
|
||||||
|
finalTie.PlayerIDs = append(finalTie.PlayerIDs, id)
|
||||||
|
}
|
||||||
|
sort.Ints(finalTie.PlayerIDs)
|
||||||
|
|
||||||
|
sort.SliceStable(finalRows, func(i, j int) bool {
|
||||||
|
if finalRows[i].Score != finalRows[j].Score {
|
||||||
|
return finalRows[i].Score > finalRows[j].Score
|
||||||
|
}
|
||||||
|
iti := tiedTop[finalRows[i].PlayerID]
|
||||||
|
itj := tiedTop[finalRows[j].PlayerID]
|
||||||
|
if iti && itj && finalRows[i].TieBreak != finalRows[j].TieBreak {
|
||||||
|
return finalRows[i].TieBreak > finalRows[j].TieBreak
|
||||||
|
}
|
||||||
|
return finalRows[i].Seed < finalRows[j].Seed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalTie.Required {
|
||||||
|
assignDenseRankBy(finalRows, func(a, b RankingRow) bool {
|
||||||
|
if a.Score != b.Score {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if tiedTop[a.PlayerID] && tiedTop[b.PlayerID] {
|
||||||
|
return a.TieBreak == b.TieBreak
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if len(finalRows) >= 3 {
|
||||||
|
third := finalRows[2]
|
||||||
|
greater := 0
|
||||||
|
equal := 0
|
||||||
|
for _, row := range finalRows {
|
||||||
|
if row.Score > third.Score {
|
||||||
|
greater++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if row.Score == third.Score {
|
||||||
|
itied := tiedTop[row.PlayerID]
|
||||||
|
ttied := tiedTop[third.PlayerID]
|
||||||
|
if itied && ttied {
|
||||||
|
if row.TieBreak > third.TieBreak {
|
||||||
|
greater++
|
||||||
|
} else if row.TieBreak == third.TieBreak {
|
||||||
|
equal++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
equal++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if greater < 3 && greater+equal > 3 {
|
||||||
|
finalTie.Resolved = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assignDenseRankByScore(finalRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
podium := []RankingRow{}
|
||||||
|
for i := 0; i < len(finalRows) && i < 3; i++ {
|
||||||
|
podium = append(podium, finalRows[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return DerivedState{
|
||||||
|
PreliminaryRanking: RankingBundle{Rows: preRows, TieBreak: preTie, Unresolved: preTie.Required && !preTie.Resolved},
|
||||||
|
Finalists: finalists,
|
||||||
|
FinalGroups: FinalGroups{Group1: finalGroup1, Group2: finalGroup2},
|
||||||
|
FinalRanking: RankingBundle{Rows: finalRows, TieBreak: finalTie, Unresolved: finalTie.Required && !finalTie.Resolved},
|
||||||
|
Podium: podium,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scoreTotal(scores map[string]map[int]int, playerID int, stages []string) int {
|
||||||
|
total := 0
|
||||||
|
for _, stage := range stages {
|
||||||
|
stageMap, ok := scores[stage]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += stageMap[playerID]
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignDenseRankByScore(rows []RankingRow) {
|
||||||
|
assignDenseRankBy(rows, func(a, b RankingRow) bool {
|
||||||
|
return a.Score == b.Score
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignDenseRankBy(rows []RankingRow, isEqual func(a, b RankingRow) bool) {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRank := 1
|
||||||
|
rows[0].Rank = currentRank
|
||||||
|
for i := 1; i < len(rows); i++ {
|
||||||
|
if !isEqual(rows[i], rows[i-1]) {
|
||||||
|
currentRank = i + 1
|
||||||
|
}
|
||||||
|
rows[i].Rank = currentRank
|
||||||
|
}
|
||||||
|
}
|
||||||
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
shooting-event:
|
||||||
|
container_name: shooting-event
|
||||||
|
image: ${IMAGE:-repo.ssp-itinfra.com/admin/shooting-event:amd64-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-8080}:8080"
|
||||||
|
environment:
|
||||||
|
PORT: "8080"
|
||||||
|
DB_PATH: /app/data/shooting.db
|
||||||
|
WEB_DIR: /app/web
|
||||||
|
ADMIN_USER: ${ADMIN_USER:-datwyler}
|
||||||
|
ADMIN_PASS: ${ADMIN_PASS:-datwyler}
|
||||||
|
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
||||||
|
GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.0-flash}
|
||||||
|
volumes:
|
||||||
|
- shooting_event_data:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/api/health >/dev/null 2>&1 || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
shooting_event_data:
|
||||||
|
name: shooting_event_data
|
||||||
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ar" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Shooting Event Tracker</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Cairo:wght@400;600;700;800&family=IBM+Plex+Mono:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
frontend/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "shooting-event-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --host 0.0.0.0 --port 4173"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.22"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"vite": "^5.4.19"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
747
frontend/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,747 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
vue:
|
||||||
|
specifier: ^3.5.22
|
||||||
|
version: 3.5.31
|
||||||
|
devDependencies:
|
||||||
|
'@vitejs/plugin-vue':
|
||||||
|
specifier: ^5.2.1
|
||||||
|
version: 5.2.4(vite@5.4.21)(vue@3.5.31)
|
||||||
|
vite:
|
||||||
|
specifier: ^5.4.19
|
||||||
|
version: 5.4.21
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.27.1':
|
||||||
|
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.28.5':
|
||||||
|
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/parser@7.29.2':
|
||||||
|
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/types@7.29.0':
|
||||||
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.21.5':
|
||||||
|
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.21.5':
|
||||||
|
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.21.5':
|
||||||
|
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.21.5':
|
||||||
|
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.21.5':
|
||||||
|
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.21.5':
|
||||||
|
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.21.5':
|
||||||
|
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5':
|
||||||
|
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm-eabi@4.60.1':
|
||||||
|
resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-arm64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-x64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-arm64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-x64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf@4.60.1':
|
||||||
|
resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
|
||||||
|
resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-musl@4.60.1':
|
||||||
|
resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-musl@4.60.1':
|
||||||
|
resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-musl@4.60.1':
|
||||||
|
resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-musl@4.60.1':
|
||||||
|
resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-s390x-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-musl@4.60.1':
|
||||||
|
resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@rollup/rollup-openbsd-x64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@rollup/rollup-openharmony-arm64@4.60.1':
|
||||||
|
resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-arm64-msvc@4.60.1':
|
||||||
|
resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-ia32-msvc@4.60.1':
|
||||||
|
resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-gnu@4.60.1':
|
||||||
|
resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-msvc@4.60.1':
|
||||||
|
resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@types/estree@1.0.8':
|
||||||
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@vitejs/plugin-vue@5.2.4':
|
||||||
|
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
vite: ^5.0.0 || ^6.0.0
|
||||||
|
vue: ^3.2.25
|
||||||
|
|
||||||
|
'@vue/compiler-core@3.5.31':
|
||||||
|
resolution: {integrity: sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==}
|
||||||
|
|
||||||
|
'@vue/compiler-dom@3.5.31':
|
||||||
|
resolution: {integrity: sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==}
|
||||||
|
|
||||||
|
'@vue/compiler-sfc@3.5.31':
|
||||||
|
resolution: {integrity: sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==}
|
||||||
|
|
||||||
|
'@vue/compiler-ssr@3.5.31':
|
||||||
|
resolution: {integrity: sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==}
|
||||||
|
|
||||||
|
'@vue/reactivity@3.5.31':
|
||||||
|
resolution: {integrity: sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==}
|
||||||
|
|
||||||
|
'@vue/runtime-core@3.5.31':
|
||||||
|
resolution: {integrity: sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==}
|
||||||
|
|
||||||
|
'@vue/runtime-dom@3.5.31':
|
||||||
|
resolution: {integrity: sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==}
|
||||||
|
|
||||||
|
'@vue/server-renderer@3.5.31':
|
||||||
|
resolution: {integrity: sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: 3.5.31
|
||||||
|
|
||||||
|
'@vue/shared@3.5.31':
|
||||||
|
resolution: {integrity: sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==}
|
||||||
|
|
||||||
|
csstype@3.2.3:
|
||||||
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
entities@7.0.1:
|
||||||
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
esbuild@0.21.5:
|
||||||
|
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
estree-walker@2.0.2:
|
||||||
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
nanoid@3.3.11:
|
||||||
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
picocolors@1.1.1:
|
||||||
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
postcss@8.5.8:
|
||||||
|
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||||
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
rollup@4.60.1:
|
||||||
|
resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==}
|
||||||
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
source-map-js@1.2.1:
|
||||||
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
vite@5.4.21:
|
||||||
|
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': ^18.0.0 || >=20.0.0
|
||||||
|
less: '*'
|
||||||
|
lightningcss: ^1.21.0
|
||||||
|
sass: '*'
|
||||||
|
sass-embedded: '*'
|
||||||
|
stylus: '*'
|
||||||
|
sugarss: '*'
|
||||||
|
terser: ^5.4.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
less:
|
||||||
|
optional: true
|
||||||
|
lightningcss:
|
||||||
|
optional: true
|
||||||
|
sass:
|
||||||
|
optional: true
|
||||||
|
sass-embedded:
|
||||||
|
optional: true
|
||||||
|
stylus:
|
||||||
|
optional: true
|
||||||
|
sugarss:
|
||||||
|
optional: true
|
||||||
|
terser:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
vue@3.5.31:
|
||||||
|
resolution: {integrity: sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.27.1': {}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.28.5': {}
|
||||||
|
|
||||||
|
'@babel/parser@7.29.2':
|
||||||
|
dependencies:
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@babel/types@7.29.0':
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-string-parser': 7.27.1
|
||||||
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.21.5':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm-eabi@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-android-arm64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-arm64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-darwin-x64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-arm64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-freebsd-x64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-arm64-musl@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-loong64-musl@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-ppc64-musl@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-riscv64-musl@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-s390x-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-linux-x64-musl@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-openbsd-x64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-openharmony-arm64@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-arm64-msvc@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-ia32-msvc@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-gnu@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@rollup/rollup-win32-x64-msvc@4.60.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@vitejs/plugin-vue@5.2.4(vite@5.4.21)(vue@3.5.31)':
|
||||||
|
dependencies:
|
||||||
|
vite: 5.4.21
|
||||||
|
vue: 3.5.31
|
||||||
|
|
||||||
|
'@vue/compiler-core@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.2
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
entities: 7.0.1
|
||||||
|
estree-walker: 2.0.2
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
'@vue/compiler-dom@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-core': 3.5.31
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
|
||||||
|
'@vue/compiler-sfc@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.2
|
||||||
|
'@vue/compiler-core': 3.5.31
|
||||||
|
'@vue/compiler-dom': 3.5.31
|
||||||
|
'@vue/compiler-ssr': 3.5.31
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
estree-walker: 2.0.2
|
||||||
|
magic-string: 0.30.21
|
||||||
|
postcss: 8.5.8
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
'@vue/compiler-ssr@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-dom': 3.5.31
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
|
||||||
|
'@vue/reactivity@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
|
||||||
|
'@vue/runtime-core@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@vue/reactivity': 3.5.31
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
|
||||||
|
'@vue/runtime-dom@3.5.31':
|
||||||
|
dependencies:
|
||||||
|
'@vue/reactivity': 3.5.31
|
||||||
|
'@vue/runtime-core': 3.5.31
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@vue/server-renderer@3.5.31(vue@3.5.31)':
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-ssr': 3.5.31
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
|
vue: 3.5.31
|
||||||
|
|
||||||
|
'@vue/shared@3.5.31': {}
|
||||||
|
|
||||||
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
entities@7.0.1: {}
|
||||||
|
|
||||||
|
esbuild@0.21.5:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/aix-ppc64': 0.21.5
|
||||||
|
'@esbuild/android-arm': 0.21.5
|
||||||
|
'@esbuild/android-arm64': 0.21.5
|
||||||
|
'@esbuild/android-x64': 0.21.5
|
||||||
|
'@esbuild/darwin-arm64': 0.21.5
|
||||||
|
'@esbuild/darwin-x64': 0.21.5
|
||||||
|
'@esbuild/freebsd-arm64': 0.21.5
|
||||||
|
'@esbuild/freebsd-x64': 0.21.5
|
||||||
|
'@esbuild/linux-arm': 0.21.5
|
||||||
|
'@esbuild/linux-arm64': 0.21.5
|
||||||
|
'@esbuild/linux-ia32': 0.21.5
|
||||||
|
'@esbuild/linux-loong64': 0.21.5
|
||||||
|
'@esbuild/linux-mips64el': 0.21.5
|
||||||
|
'@esbuild/linux-ppc64': 0.21.5
|
||||||
|
'@esbuild/linux-riscv64': 0.21.5
|
||||||
|
'@esbuild/linux-s390x': 0.21.5
|
||||||
|
'@esbuild/linux-x64': 0.21.5
|
||||||
|
'@esbuild/netbsd-x64': 0.21.5
|
||||||
|
'@esbuild/openbsd-x64': 0.21.5
|
||||||
|
'@esbuild/sunos-x64': 0.21.5
|
||||||
|
'@esbuild/win32-arm64': 0.21.5
|
||||||
|
'@esbuild/win32-ia32': 0.21.5
|
||||||
|
'@esbuild/win32-x64': 0.21.5
|
||||||
|
|
||||||
|
estree-walker@2.0.2: {}
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
magic-string@0.30.21:
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
postcss@8.5.8:
|
||||||
|
dependencies:
|
||||||
|
nanoid: 3.3.11
|
||||||
|
picocolors: 1.1.1
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
rollup@4.60.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.8
|
||||||
|
optionalDependencies:
|
||||||
|
'@rollup/rollup-android-arm-eabi': 4.60.1
|
||||||
|
'@rollup/rollup-android-arm64': 4.60.1
|
||||||
|
'@rollup/rollup-darwin-arm64': 4.60.1
|
||||||
|
'@rollup/rollup-darwin-x64': 4.60.1
|
||||||
|
'@rollup/rollup-freebsd-arm64': 4.60.1
|
||||||
|
'@rollup/rollup-freebsd-x64': 4.60.1
|
||||||
|
'@rollup/rollup-linux-arm-gnueabihf': 4.60.1
|
||||||
|
'@rollup/rollup-linux-arm-musleabihf': 4.60.1
|
||||||
|
'@rollup/rollup-linux-arm64-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-linux-arm64-musl': 4.60.1
|
||||||
|
'@rollup/rollup-linux-loong64-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-linux-loong64-musl': 4.60.1
|
||||||
|
'@rollup/rollup-linux-ppc64-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-linux-ppc64-musl': 4.60.1
|
||||||
|
'@rollup/rollup-linux-riscv64-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-linux-riscv64-musl': 4.60.1
|
||||||
|
'@rollup/rollup-linux-s390x-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-linux-x64-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-linux-x64-musl': 4.60.1
|
||||||
|
'@rollup/rollup-openbsd-x64': 4.60.1
|
||||||
|
'@rollup/rollup-openharmony-arm64': 4.60.1
|
||||||
|
'@rollup/rollup-win32-arm64-msvc': 4.60.1
|
||||||
|
'@rollup/rollup-win32-ia32-msvc': 4.60.1
|
||||||
|
'@rollup/rollup-win32-x64-gnu': 4.60.1
|
||||||
|
'@rollup/rollup-win32-x64-msvc': 4.60.1
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
vite@5.4.21:
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.21.5
|
||||||
|
postcss: 8.5.8
|
||||||
|
rollup: 4.60.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
vue@3.5.31:
|
||||||
|
dependencies:
|
||||||
|
'@vue/compiler-dom': 3.5.31
|
||||||
|
'@vue/compiler-sfc': 3.5.31
|
||||||
|
'@vue/runtime-dom': 3.5.31
|
||||||
|
'@vue/server-renderer': 3.5.31(vue@3.5.31)
|
||||||
|
'@vue/shared': 3.5.31
|
||||||
BIN
frontend/public/arture.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
frontend/public/bg.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
frontend/public/bg_live.png
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
frontend/public/datwyler_logo.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/logo.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
63
frontend/public/logo.svg
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 583.65 114.56">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #008ca8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #ec2f23;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #1b1f40;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g>
|
||||||
|
<path class="cls-3" d="M545.23,98.25l4.31-7.15c.28-.46.13-1.06-.33-1.33-2.22-1.32-8.46-4.34-10.8-5.19-.64-.23-.88-.38-1.41.22-.65.73-4.89,7.9-4.78,8.41.1.46,8,4.3,11.13,5.65.69.3,1.5.04,1.9-.61Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-3" d="M392.94,85.7c5.81.93,14.01.79,19.19-2.28,3.16-1.87,4.61-5.35,5.87-8.63l1.32,4.87c3.19,8.82,8.21,15.31,18.58,12.74,10.58-2.63,13.61-18.14,12.73-27.42-.65-6.87-2.57-13.59-3.36-20.44l1.61-.11,3.36-9.01c.02-.45-.27-.62-.56-.87-2.28-1.94-6.47-3.24-8.09-6.06-1.38-2.41-.88-4.59-.39-7.13-.32-.06-.53.03-.81.16-1.84.87-5.37,7.2-5.99,9.22-.44,1.44-.52,2.45,0,3.88.32.91,1.51,2.23,1.64,2.78.19.82-.16,1.4-.15,2.08.29,10.93,4.43,22.34,6.47,33,.24,1.27,1.13,5.76.62,6.65-.87,1.53-4.98,2.6-6.65,2.73-13.69,1.08-17.23-22.88-17.26-24.5-.02-1.63,3.27-6.22,4.17-8.05.29-.6,1.45-2.94,1.31-3.43l-.52-.21-5.3,7.35c-.93-3.2-1.96-6.43-2.15-9.79-.69-.95-1.17.03-1.63.61-2.69,3.35-3.93,5.91-3.64,10.33.04.65.56,1.64.19,2.14-7.63,4.11-16.83,6.5-21.56,14.35-1.4,2.33-4.47,9.4-4.06,11.96.39,2.45,3.05,2.76,5.05,3.08ZM398.75,71.81c5.39-3.26,11.6-5.51,16.24-9.94.49,2.58,1.29,5.14,1.62,7.75.07.57.35,1.65,0,2.1-.33.43-3.52,2.14-4.2,2.44-7.89,3.39-16.94.64-17.15.36-.11-.47,2.94-2.38,3.48-2.71Z"/>
|
||||||
|
<path class="cls-3" d="M384.64,43.49l10.08,4.98c.43.21.94.06,1.19-.35l3.86-6.41c3.08.93,5.9,2.66,8.65,4.33.21.12.41.05.58-.09l5.38-7.11c.28-.37.19-.89-.2-1.14-1.87-1.21-7.19-4.06-9.28-4.89-1.71-.67-1.63-.51-2.65.83-1.28,1.68-2.21,3.59-3.39,5.34-.64.19-6.86-3.71-9.28-4.05-.37-.05-.74.13-.94.45l-4.34,6.88c-.27.43-.11,1,.35,1.23Z"/>
|
||||||
|
<path class="cls-1" d="M412.38,30.98c5.42-3.61,12.17-5.45,18.12-8.01,1.55-.67,4.04-1.64,5.19-2.83.46-.48,2.17-3.45,2.03-3.98l-.35-.22c-1.06.57-2.11,1.19-3.21,1.69-5.69,2.56-12.2,4.4-17.73,7.18-3.1,1.56-5.78,3.95-6.06,7.62.12.14,1.76-1.28,2-1.44Z"/>
|
||||||
|
<path class="cls-1" d="M544.19,36.97c.53-.43,2.67-2.92,2.87-2.97,1.09-.27,2.33,2.56,3.41.76,1.27-2.13.29-4.12-1.85-5.02,3.63-5.13-1.82-8.77-5.51-4-2.26,2.93-2.19,6,1.66,7.24.3.4-5.23,3.9-5.71,4.19-2.66,1.58-5.51,2.96-8.26,4.39-.68.35-2.27.92-2.73,1.27-.28.21-.32.27-.27.64,8.41-1.44,14.63-5.05,16.39-6.5ZM544.77,28.23c-.28-1.72,3.07-1.19,2.34.92-.62.13-2.23-.22-2.34-.92Z"/>
|
||||||
|
<path class="cls-1" d="M487.55,49.01c.45-.37,2.28-2.5,2.46-2.54.93-.23,2,2.19,2.92.65,1.09-1.82.25-3.53-1.58-4.3,3.11-4.39-1.56-7.51-4.72-3.42-1.93,2.51-1.88,5.14,1.42,6.2.26.35-4.47,3.34-4.89,3.58-2.28,1.35-4.72,2.53-7.07,3.75-.58.3-1.94.78-2.33,1.08-.24.18-.28.23-.23.55,7.19-1.23,12.52-4.32,14.03-5.56ZM488.04,41.53c-.24-1.47,2.63-1.02,2,.79-.53.12-1.91-.19-2-.79Z"/>
|
||||||
|
<path class="cls-2" d="M475.87,43.99c.16-.42,1.45-4.23.57-4.22l-2.7,2.54c-.95.9-2.32,1.27-3.56.88-2.75-.86-3.43-4.62-3.2-7.68.37-4.86,3.22-9.04,4.99-13.43-.05-.06-.59.33-.71.44-4.34,4.2-8.86,15.7-7.73,21.64,1.59,8.37,9.66,6.77,12.34-.16Z"/>
|
||||||
|
<path class="cls-3" d="M560.15,59.59c-.48-6.52-4.32-9.98-8.51-15.45-.96-.25-1.27.94-1.6,1.63-.78,1.57-2.91,6.79-2.81,8.35.16,2.1,4.24,6.97,5.41,9.28.31.61,1.54,3.25,1.24,3.73-.45.71-7.04,2.47-8.23,2.74-4.77,1.09-9.7,1.58-14.5,2.56-.26-.23,6.7-5.19,9.46-13.62,1.79-5.5-6.64-10.43-11.05-11.41-7.4-1.67-15.05,3.33-20.35,7.96l-4.23,4.17c.47-1.51,1.23-2.9,1.67-4.43,2.64-9.01-.34-20.1-.44-20.34.63-.09,1.27.71,1.67-.01.87-2.38,1.4-4.98,2.45-7.3.22-.5,1.13-1.55,1-1.95-5.81-3.68-12.82-6.43-10.61-13.11-.27-1.24-2.06,1.48-2.25,1.79-1.2,1.99-3.4,6.92-3.4,9.17,0,3.07,2.12,4.11,2.46,5.87.12.63-.18,1.95-.16,2.8.14,4.15,2.12,9.94,2.96,14.21,1.93,9.78,2.55,16.11-4.81,23.74-.72.75-9.19,3.71-10.2,2.78-2.26-6.39-10.51-19.18-18.41-12.73-3.52,2.87-6.25,8.73-7.48,13.04-2.5,8.83,4.32,10.97,11.65,11.28,2.5.12,4.52-.38,6.9-.52,1.11-.08,1.99-.09,1.23,1.22-.92,1.58-15.56,16.28-29.89,15.6-2.72-.13-10.67-1.83-14.21-2.81-.34-.09-.56.36-.27.57.75.54,10.15,7.16,16.92,8.41,1.27.23,3.27.21,4.65.21,0,0-1.44.38-1.44.7,0,.1.12.65,1.23.89.83.18,2.45.26,2.45.26,9.13-.07,15.98-8.55,19.69-13.13,2.55-3.14,4.57-6.66,5.78-10.36.14-.41.17-1.55.6-1.7.53-.18,26.49-2.3,34.5-1.32,9.25,1.15,24.69-1.36,32.76-6.16,4.68-2.78,8.59-11.22,8.2-16.59ZM465.63,71.52c-.88-1.54,1.33-3.08,2.77-3.09,1.67-.01,6.42,4.66,6.26,5.29-1.81-.05-8.13-.62-9.03-2.2ZM500.54,70.49c-.08-.31.13-.36.26-.53.7-.92,1.9-1.73,2.74-2.56,4.37-4.31,11.57-10.71,18.14-10.36,3.91.21,7.97,2.72,10.69,5.41.06.28-.23.36-.4.47-.84.56-2.8,1.31-3.79,1.7-8.75,3.39-18.36,4.8-27.64,5.89Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="cls-2" d="M217.93,56.12c2.78-1.56,3.83-5.94,4.41-8.84l-.31-.22c-1.28,2.08-4,4.11-6.31,2.14-2.91-2.49-1.56-7.07-.57-10.19.99-3.12,2.59-5.94,3.94-8.89,0,0-12.84,14.54-7.4,24.01,1.32,2.29,3.82,3.34,6.23,1.98Z"/>
|
||||||
|
<path class="cls-1" d="M339.55,40.67c-.21.38-1.07,2.03-1.03,2.32.02.13.31.35.47.12,5.9-4.13,13.01-5.94,19.63-8.59,4.02-1.61,7.78-2.67,10.3-6.45.73-1.09,1.59-2.51,1.51-3.86l-2.7,1.79c-8.01,5.05-23.37,5.98-28.18,14.67Z"/>
|
||||||
|
<path class="cls-2" d="M251.93,48.45c2.1,1.09,3.59-.9,4.9-2.21,2.22,1.02,4.11.54,5.3-1.62,1.28-2.33.53-4.16-.14-6.51-.85-.84-1.13,1.52-1.14,2.06,0,1.09.59,2.4-.66,3.18-1.34.83-2.38-.6-2.52-1.85-.07-.63,0-1.46,0-2.12,0-.74-.76.64-.82.77-.47,1.06-.45,1.75-.67,2.78-.59,2.71-3.83,3.62-4,.25-.02-.42.22-1.76.17-1.92-.3-1.14-1.3,1.41-1.38,1.65-.51,1.6-.84,4.62.95,5.55Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-3" d="M371.43,67.19c-1.92-5.4-10.44-12.79-16.07-15.06-2.28-.92-4.29-.92-5.95,0-2.69,1.49-3.83,5.03-4.31,7.74-.25,1.55-.59,4.2,0,4.62l.2.14.24-.06c.68-.17,1.25-.53,1.8-.88.74-.46,1.37-.86,2.21-.86,2.34,0,8.55,5.01,11.36,7.7,1.77,1.69,3.56,3.77,5.08,5.88-.43.22-1.49.65-4.1,1.45l-.11.03c-3.7,1.13-7.91,2.17-11.53,2.85-.22.04-.46.09-.72.14-2.51.5-6.71,1.33-7.93-.74-.15-.26-.25-.58-.35-.91-.16-.55-.35-1.17-.87-1.63l-.15-.14h-.2c-.48,0-.73.14-1.62,3.9-1.22,5.13-.98,8.35.76,10.12,1.07,1.08,2.69,1.61,4.97,1.61,1.81,0,4.05-.31,7.26-1.01,3.87-.85,15.21-3.89,17.47-6.22.98-1.01,1.5-2.63,1.67-3.28,1.25-4.65,2.41-11.09.88-15.39Z"/>
|
||||||
|
<path class="cls-3" d="M334.01,41.61l-.26.06.26-.07c-1.94-8.37-3.96-17.03-3.69-24.32,0-.1.05-.23.1-.36.13-.34.33-.85-.03-1.44l-.12-.2-.23-.05c-.98-.2-1.59.44-2.05.91l-.1.11c-6.98,7.12-6.41,14.05-4.94,22.37.83,4.72,2,9.24,3.13,13.64,2.89,11.24,5.63,21.85,2.6,34.3-.08.31-.46,1.01-.77,1.57-.48.87-.75,1.38-.79,1.73-.05.49.17,1.06.68,1.18.04,0,.08.02.14.02.29,0,.61-.16,2.15-2.17,5.72-7.49,8.5-19.01,7.26-30.08-.61-5.44-2-11.42-3.34-17.2Z"/>
|
||||||
|
<path class="cls-3" d="M272.08,56.27l.25-.08-.25.08c2.16,6.63,4.2,12.89,3.73,20.06-.06.93-.34,2.15-.62,3.35-.33,1.43-.65,2.78-.63,3.76,0,.32.02.69.5,1.03l.39.27.31-.36c2.71-3.13,4.14-7.58,5.09-11.1,2.62-9.78,1.49-16.82-.91-26.14,0,0-.82-3.09-1.35-4.88-1.28-4.27-3.42-8.98-3.15-12.78.01-.17.09-.5.17-.84.25-1.09.54-2.32-.02-2.95-.13-.15-.43-.4-.96-.35-1.13.11-4.09,4.59-4.11,4.63-3.24,5.18-2.75,8.94-1.68,14.66.76,4.03,2.02,7.9,3.24,11.65Z"/>
|
||||||
|
<path class="cls-3" d="M287.73,37.28l10.4,5.14c.2.1.42.15.65.15.52,0,1-.27,1.27-.71l3.77-6.26c2.94.96,5.68,2.63,8.33,4.24l.09.06c.4.23.85.16,1.28-.24l5.55-7.34c.23-.3.32-.68.25-1.05-.07-.37-.28-.7-.6-.9-2.01-1.3-7.55-4.26-9.67-5.09-1.85-.73-2.16-.56-3.11.71l-.24.32c-.79,1.04-1.45,2.16-2.12,3.28-.39.65-.79,1.33-1.21,1.97-.54-.19-1.77-.83-2.8-1.36-2.2-1.13-4.93-2.54-6.44-2.76-.6-.09-1.19.19-1.51.71l-4.48,7.1c-.21.34-.27.75-.17,1.14.11.39.38.71.74.89Z"/>
|
||||||
|
<path class="cls-3" d="M269.89,96.65l.1.06c.41.23.85.15,1.28-.24l5.55-7.34c.23-.3.32-.68.25-1.05s-.28-.7-.6-.9c-2.01-1.3-7.55-4.26-9.67-5.09-1.85-.73-2.16-.56-3.11.71l-.24.32c-.78,1.03-1.45,2.15-2.1,3.25-.39.66-.8,1.35-1.23,2-.54-.19-1.77-.83-2.8-1.36-2.2-1.13-4.93-2.54-6.44-2.76-.6-.09-1.19.19-1.51.71l-4.48,7.1c-.21.34-.27.75-.17,1.14.11.39.38.71.74.89l10.4,5.14c.2.1.42.15.65.15.52,0,1-.27,1.27-.71l3.77-6.26c2.94.96,5.68,2.63,8.33,4.24Z"/>
|
||||||
|
<path class="cls-2" d="M321.83,98.85h0c-.22.09-.44.3-.81.67-.29.28-.69.67-.94.79-1.38.68-2.96.36-3.76-.76-1.41-1.97-.24-5.48.76-7.94.22-.53.57-1.17.92-1.81.43-.78.87-1.59,1.08-2.25.13-.4.23-.71.03-1.27l-.11-.29-.3-.05c-.64-.12-.95.33-1.11.56-.02.03-.04.06-.07.09-2.28,2.79-6.98,10.67-6.09,15.95.29,1.71,1.13,3.01,2.5,3.85.79.48,1.61.73,2.46.73.73,0,1.47-.19,2.2-.55,2.26-1.13,4.14-3.97,4.37-6.6.02-.2-.02-.78-.4-1.05-.21-.15-.48-.18-.73-.07Z"/>
|
||||||
|
<path class="cls-2" d="M311.94,13.76h0c-.15.07-.31.21-.58.47-.21.2-.49.47-.67.56-.97.48-2.09.26-2.66-.54-1-1.4-.17-3.88.54-5.62.15-.38.4-.83.65-1.28.3-.55.62-1.12.77-1.59.09-.28.16-.5.02-.9l-.07-.2-.21-.04c-.45-.08-.67.23-.79.4-.02.02-.03.05-.05.07-1.61,1.97-4.94,7.55-4.31,11.29.2,1.21.8,2.13,1.77,2.72.56.34,1.14.52,1.74.52.52,0,1.04-.13,1.56-.39,1.6-.8,2.93-2.81,3.09-4.67.01-.14-.01-.55-.28-.75-.15-.11-.34-.13-.51-.05Z"/>
|
||||||
|
<path class="cls-3" d="M324.39,61.48c-.46-4.68-6.51-11.83-8.36-13.91-.37-.4-.7-.72-1.03-.98l-.06-.04h-.07c-.37-.07-.58.22-.71.39l-.04.05c-1.1,1.4-3.82,6.09-4.01,7.99-.23,2.25,1.86,4.95,3.53,7.13.52.68,1.02,1.32,1.39,1.88.08.13.19.29.32.47.63.92,2.09,3.06,2.07,3.74,0,.12-.22.8-3.83,2.4-1.45.63-3.84,1.5-4.41,1.5-1.17-4.42-6.15-11.55-11.48-12.92-2.47-.64-4.66.05-6.51,2.05-2.31,2.49-4.2,6.39-5.2,9.22-1.55,4.38-2.29,7.83-.87,10.45.99,1.81,2.94,3.04,5.98,3.76,3.75.89,8.59.71,12.73-.47-.07.19-.18.42-.28.62-1.7,3.37-15.28,16.69-29.4,16.03-2.54-.12-9.74-2.12-13.6-3.2l-.94-.26c-.28-.08-.56.06-.69.32-.13.26-.05.57.18.74.97.7,1.98,1.4,3.02,2.08,3.88,2.52,9.54,5.71,14.49,6.61.93.17,2.79.1,3.98.1-.07.04-.71.2-.76.25-.1.1-.15.21-.14.33,0,.09.06.86,1.47,1.17.82.18,1.64.27,2.45.27,3.53,0,7.57-1.23,10.55-3.2,4.24-2.81,13.94-12.5,15.28-23.12,5.5-2.28,9.66-4.87,12.1-10,.77-1.6,3.23-7.14,2.82-11.46h0ZM298.91,73.94c-.72.07-2.88-.02-3.4-.08-1.46-.18-4.19-.96-4.43-1.88-.11-.4.28-1.4.66-1.7.5-.39,1.37-.39,2.47-.02,1.99.68,4.16,2.4,4.7,3.69Z"/>
|
||||||
|
<path class="cls-3" d="M267.79,59.46c-1.01-2.05-2.43-4.14-4.23-6.49-.09-.12-.19-.28-.28-.45-.33-.56-.79-1.32-1.46-1.29-.44.01-.8.33-1.1.96-.8,1.61-1.9,4.71-2.49,6.41l-.16.43c-.44,1.25-.95,2.67-.37,4.02.49,1.14,1.32,2.37,2.14,3.57.88,1.28,2.46,4.25,2.46,4.38,0,.01-.07.16-.57.35-.66.24-9.67,2.56-11.43-4.79-1.35-5.65-6.8-34.72-9.24-47.44-.47-2.44-1.6-4.55-2.34-3.71-.8.9-5.73,6.31-6.12,7.53-.09.31-.24.53-.15.89,0,.04.01.08.03.12,3.66,10.92,5.66,23.65,7.3,35.05,0,.01.01.03.01.04.31,2.12.64,4.16,1.04,6.11-.01.45-.2.9-.52,1.37-.55.73-1.52,1.46-2.73,2.05-1.05.51-2.21.86-3.09.96-.88.09-1.78.07-2.71-.08-2.87-.44-5.53-2.01-7.09-4.18-.17-.24-.35-.59-.53-.93-.25-.49-.52-1.01-.83-1.33l-.07-.07c-.51-.59-.94-.48-1.2-.32-.44.23-.72.92-1.05,1.81l-.08.24c-.8,2.04-2.18,6.03-2.33,8.09-.05.71.01,1.41.07,2.09l.04.35.16.15c1.84,1.56,3.33,2.93,4.6,4.51l.2.23c.88,1.08,1.06,1.34,1.09,1.38.2.71-.12,1.8-.92,3.18-.86,1.49-2.17,3.01-3.02,4l-.43.49c-13.12,15.57-24.05,12.43-28.72,11.08-.47-.13-.88-.24-1.22-.33l-.76-.17.12.77c.59,3.86,8.51,7.34,8.86,7.49l.17.05c.81.13,1.64.21,2.43.27-.11.17-.15.39-.13.61l.03.35.32.12c.44.17.9.31,1.38.4.68.13,1.38.2,2.16.2,1.13,0,2.38-.15,3.85-.45,11.24-2.32,18.68-13.46,21.06-23.48.07-.29.15-.77.27-1.46.12-.75.44-2.71.63-2.97.16-.2,1.13-.41,1.6-.52.36-.08.69-.15.95-.23,4.9-1.66,7.94-4.18,9.58-8.01,4.06,10.53,12.35,10.27,13.62,10.3,4.15.08,7.65-2.12,9.62-6.01.48-.96.9-1.86,1.28-2.74,2.61-6.15,2.5-10.42.32-14.91Z"/>
|
||||||
|
</g>
|
||||||
|
<path class="cls-1" d="M263.48,30.62c.13-.89-.04-.9-.76-.62-2.19.82-4.7,1.96-6.84,2.95-2.94,1.36-5.86,2.56-6.26,6.24.16.17,2.1-1.17,2.42-1.34,2.38-1.27,4.98-2.14,7.41-3.3,1.63-.78,3.73-1.94,4.02-3.92Z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path class="cls-3" d="M49.47,41.16c-.18.16-.38.22-.61.1-2.87-1.75-5.83-3.55-9.05-4.53l-4.04,6.71c-.26.43-.8.58-1.24.36l-10.55-5.21c-.48-.24-.65-.83-.36-1.28l4.54-7.2c.21-.34.61-.53,1-.48,2.53.36,9.03,4.43,9.7,4.23,1.23-1.82,2.21-3.83,3.55-5.59,1.07-1.41.98-1.58,2.77-.87,2.19.86,7.75,3.85,9.71,5.12.41.26.5.81.21,1.19l-5.63,7.44Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M115.09,48.87c8.37-4.02,18.12-7.1,26.81-10.56,5.22-2.08,9.22-2.94,11.61-8.6.15-.36.88-2.04.68-2.27l-3.54,1.92c-10.25,4.41-21.04,7.42-31.32,11.77-3.89,1.65-9.13,3.8-11.8,7.15-1.55,1.94-2.79,4.69-3.22,7.14l.36.21c3.1-2.61,6.76-4.99,10.42-6.75Z"/>
|
||||||
|
<path class="cls-1" d="M191.25,86.23c-8.15,3.26-17.96,5.21-25.73,8.78-3.2,1.47-5.5,3.98-6.5,7.36.26.05.44-.06.66-.14,2.34-.85,4.92-2.43,7.33-3.39,8.27-3.3,17.66-5.33,25.68-9.19,3.11-1.5,6.29-4.34,7.44-7.66-1.15.52-2.2,1.2-3.33,1.76-1.8.88-3.7,1.74-5.56,2.49Z"/>
|
||||||
|
<path class="cls-2" d="M141.79,56.7c1.9.04,2.21-1.19,3.19-2.26.16-.17,1.2-1.24,1.29-1.26.78-.19,1.62.6,2.3.71,3.33.53,6.08-2.7,6.16-5.81.02-.66-.97-5.78-1.26-5.98-.57-.39-1.14,1.15-1.21,1.56-.25,1.51.44,4.42-.59,5.49-.99,1.02-2.4.7-3.25-.3-.96-1.14-.75-2.62-.85-3.98-.06-.9-.49-.32-.75.15-1.22,2.15-.69,6.93-3.99,7.11-3.16.17-2.45-3.89-2.23-5.91-.54-.54-1.47,1.85-1.61,2.23-1,2.84-1.5,8.16,2.8,8.25Z"/>
|
||||||
|
<path class="cls-1" d="M69.89,49.89c3.39-1.55,9.76-3.16,11.37-6.74.2-.44.72-1.93-.17-1.8-3.48,1.66-7.18,2.86-10.66,4.51-3.02,1.43-6.09,3.25-6.67,6.86l.3.18c1.98-.92,3.83-2.1,5.82-3.01Z"/>
|
||||||
|
<path class="cls-2" d="M69.76,42.61c-.02.4.24.41.49.29.49-.23,1.84-4.67,2-5.45,1.41-6.83-1.25-13.04-6.48-17.42-.51-.42-1.85-1.63-2.47-1.35-.85.39-2.28,3.33-2.13,4.27.22,1.36,2.26,2.29,3.2,3.09,4.58,3.93,6.88,7.95,5.96,14.19-.11.77-.53,1.64-.57,2.38Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-3" d="M194.12,38.09l3.92-8.92c.17-.48.16-.87-.03-1.19-.1-.17-.35-.31-2.33-1.16-.64-.28-1.25-.54-1.47-.66-4.53-2.45-6.82-5.24-6.81-8.29,0-.25.11-.67.22-1.08.23-.85.42-1.59-.01-1.89-.38-.27-.93.05-1.42.39-2.16,1.53-5.87,8.92-5.5,11.73.12.91.63,1.72,1.08,2.44.32.5.61.98.68,1.35.13.69.12,1.71.12,2.7,0,.85-.01,1.73.07,2.45.14,1.27.41,2.65.66,3.99.1.52.2,1.03.29,1.53.73,4.03,1.66,8.09,2.57,12.02,1.23,5.34,2.51,10.86,3.27,16.38-.26.38-2.31,1.21-2.93,1.32-6.58,1.18-9.6-5.39-10.98-11.11-2.08-8.64-3.48-17.89-4.84-26.83-.59-3.87-1.19-7.87-1.84-11.64l.17-3.44-.11-.1c-.15-.15-.34-.22-.57-.2-.25.02-1.01.09-3.12,3.05-4.42,6.19-4.12,8.32-3.1,15.38.54,3.77,1.36,7.69,2.15,11.48,1.08,5.21,2.2,10.6,2.65,15.71.15,1.75-.16,2.65-1.33,3.85-2.46,2.5-5.33,2.72-7.31,2.47-2.54-.33-5.15-1.65-7.16-3.62-.22-.21-.53-.63-.83-1.03-.44-.58-.85-1.13-1.16-1.35-.22-.15-.58-.4-1.03-.08l-.07.05-4.3,9.93-.03.12c-.03,1.1,1.7,2.68,3.96,4.63.74.63,1.37,1.18,1.7,1.55.19.76-1.79,3.84-2.93,5.24-5.59,6.92-16.72,13.68-26.31,13.23-2.8-.13-10.92-2.12-14.56-3.13-.35-.1-.58.36-.28.58,3.09,2.23,10.85,7.46,17.39,8.65,1.31.24,3.53.26,4.93.26l-.84.21-.69.17-.07.14c-.06.14-.07.29,0,.42.13.27.49.46,1.22.65.74.19,1.55.27,2.41.27,3.53,0,7.81-1.44,10.4-3.16,7.58-5.01,12.84-13.13,14.45-22.3,2.38-.09,6.55-.59,9.72-2.85,2.1-1.49,3.93-4.35,5.02-7.77l.98,3.14c2.23,5.21,6.28,8.5,10.83,8.8,4.27.28,8.26-2.2,10.97-6.78,3.65-6.18,4.95-12.61,3.98-19.64-.41-2.99-1.09-6.01-1.75-8.93-.65-2.88-1.32-5.84-1.73-8.78.4.16,1.08.36,1.63-.32Z"/>
|
||||||
|
<path class="cls-3" d="M72.15,88.67c1.62,0,3.24-.28,4.8-.83,3.76-1.34,6.73-4.15,8.38-7.93,4.34-9.96,2.27-14.86-4.37-22.74-.14-.17-.29-.4-.46-.65-.51-.78-1.14-1.75-2-1.61l-.11.04c-.51.27-2.95,5.9-2.97,5.96-.44,1.23-.88,2.66-.76,3.84.14,1.34,1.85,3.88,3.51,6.33.86,1.27,1.67,2.47,2.08,3.26l.1.18c.51.96.66,1.38.68,1.54-3.41,1.96-7.57,2.02-11.45.18-6.66-3.17-8.85-12.14-10.61-19.35-.39-1.61-.77-3.14-1.17-4.54.06-.25.62-.96.93-1.34.25-.31.46-.58.59-.78.65-1.05,1.34-2.33,1.68-3,.09-.17.19-.35.28-.53.49-.9,1.04-1.92,1.15-2.93l.1-.94-5.6,5.6-2.05-9.19-.11-.07c-.09-.06-.25-.13-.46-.09-.64.12-1.39,1.18-1.4,1.19-1.27,1.86-3.66,7.82-3.68,10.17,0,.46.09.91.19,1.36.07.35.15.68.17,1l-.41.15c-1.44.53-2.93,1.08-4.33,1.72l-.31.14c-5.44,2.47-12.21,5.54-15.63,10.65-1.37,2.04-3.89,7.89-4.26,10.23-.48,3.03.9,3.67,3.24,4.36,5.32,1.58,16.59.97,21.27-1.83,2.78-1.67,3.64-4.11,4.55-6.7.22-.62.44-1.26.7-1.89,1.09,5.27,3.89,13.81,10.81,17.37,2.21,1.14,4.57,1.71,6.92,1.71ZM33.63,68.58c.09-.06.17-.11.25-.17,2.4-1.68,5.2-3.12,7.92-4.51,2.74-1.4,5.57-2.86,8.04-4.57.31-.22.61-.5.91-.77.23-.22.45-.42.65-.56l2.11,8.77c-.48.47-3.29,1.78-4.2,2.11-4.94,1.79-10.34,1.96-16.51.51.09-.29.42-.52.85-.8Z"/>
|
||||||
|
<path class="cls-3" d="M106.79,87.85c3.39-.34,7.1-4.21,8.72-8.67,3.47,1.19,7.15,2.4,10.89,3.22l.33.07c2.85.63,5.89,1.23,6.85.8.48-.21.71-.84.9-1.35.04-.11.08-.21.12-.3.51-1.18,3.08-7.19,3.42-9.56.56-3.84-2.05-9.51-5.8-12.65-2.5-2.09-5.21-2.79-7.64-1.98-4.72,1.58-6.8,6.35-8.81,10.97-1.23,2.82-2.39,5.49-4.08,7.19-1.4,1.42-2.91,2.03-4.5,1.81-2.72-.37-6.25-2.79-7.12-5.45-3.05-9.37-4.66-28.07-7.84-47.56-.39-2.41-1.53-4.35-2.31-3.66-.89.79-5.65,6.21-6.03,7.42-.11.34-.27.57-.13.99,8.04,24.03,1.75,60.83,23.02,58.68ZM120.95,68.88c.71-1.13,1.64-1.57,2.85-1.36,2.43.43,5.04,3.37,5.7,4.44-1.33-.22-4.76-1.4-6.16-1.96l-.17-.07c-1.57-.61-2.06-.92-2.21-1.06Z"/>
|
||||||
|
<path class="cls-3" d="M96.14,94.72c-2.04-1.31-7.63-4.3-9.77-5.15-1.77-.7-1.99-.58-2.91.65l-.25.33c-.79,1.04-1.46,2.17-2.11,3.27-.43.72-.87,1.47-1.34,2.18-.47-.12-1.81-.82-3.02-1.44-2.21-1.14-4.97-2.57-6.47-2.78-.52-.07-1.04.17-1.33.63l-4.54,7.2c-.19.29-.24.65-.14.99.09.33.33.61.64.77l10.55,5.21c.18.09.37.13.57.13.44,0,.87-.22,1.11-.62l3.91-6.49c3.06.97,5.9,2.7,8.65,4.37l.09.05c.19.11.57.23.99-.14l5.67-7.49c.19-.26.27-.59.22-.9-.06-.32-.24-.6-.51-.77Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/public/logo_white.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
frontend/public/simon_logo.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
1988
frontend/src/App.vue
Normal file
35
frontend/src/components/AdminLoginPanel.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<section class="panel admin-login-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('adminLogin') }}</h2>
|
||||||
|
<p>{{ t('adminLoginDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-login-grid">
|
||||||
|
<input :value="username" class="name-input" :placeholder="t('auth.username')" @input="$emit('update:username', $event.target.value)" />
|
||||||
|
<input
|
||||||
|
:value="password"
|
||||||
|
type="password"
|
||||||
|
class="name-input"
|
||||||
|
:placeholder="t('auth.password')"
|
||||||
|
@input="$emit('update:password', $event.target.value)"
|
||||||
|
@keyup.enter="$emit('submit')"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary" @click="$emit('submit')">{{ t('actions.login') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="auth-error" v-if="error">{{ error }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
username: { type: String, default: '' },
|
||||||
|
password: { type: String, default: '' },
|
||||||
|
error: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['submit', 'update:username', 'update:password'])
|
||||||
|
</script>
|
||||||
|
|
||||||
451
frontend/src/components/AdminPanel.vue
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section class="panel admin-head-panel">
|
||||||
|
<div class="admin-head">
|
||||||
|
<div>
|
||||||
|
<h2>{{ t('adminPanel') }}</h2>
|
||||||
|
<p class="muted">{{ t('adminPanelDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn btn-secondary" @click="$emit('refresh')">{{ t('actions.refresh') }}</button>
|
||||||
|
<button class="btn btn-danger" @click="$emit('logout')">{{ t('actions.logout') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-settings-row">
|
||||||
|
<label class="switch-row">
|
||||||
|
<input type="checkbox" :checked="viewProofInView" @change="$emit('toggle-view-proof', $event.target.checked)" />
|
||||||
|
<span>{{ t('labels.viewProofInView') }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="tab-bar admin-tab-bar">
|
||||||
|
<button v-for="tab in adminTabs" :key="tab.id" class="tab-btn" :class="{ active: adminTab === tab.id }" @click="$emit('change-admin-tab', tab.id)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<PlayersManagementTab
|
||||||
|
v-show="adminTab === 'players'"
|
||||||
|
:t="t"
|
||||||
|
:group-setup-input="groupSetupInput"
|
||||||
|
:admin-group-cards="adminGroupCards"
|
||||||
|
:new-player="newPlayer"
|
||||||
|
:assignable-groups="assignableGroups"
|
||||||
|
:players-sorted="playersSorted"
|
||||||
|
:player-filter="playerFilter"
|
||||||
|
:player-sort="playerSort"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:group-option-label="groupOptionLabel"
|
||||||
|
:normalized-group-code="normalizedGroupCode"
|
||||||
|
@update:group-setup-input="$emit('update:group-setup-input', $event)"
|
||||||
|
@save-group-setup="$emit('save-group-setup')"
|
||||||
|
@auto-group-even="$emit('auto-group-even')"
|
||||||
|
@update:new-player="$emit('update:new-player', $event)"
|
||||||
|
@create-player="$emit('create-player')"
|
||||||
|
@convert-new-name="$emit('convert-new-name', $event)"
|
||||||
|
@update:player-filter="$emit('update:player-filter', $event)"
|
||||||
|
@update:player-sort="$emit('update:player-sort', $event)"
|
||||||
|
@open-image-uploader="$emit('open-image-uploader', $event)"
|
||||||
|
@update-player-field="$emit('update-player-field', $event)"
|
||||||
|
@update-player-group="$emit('update-player-group', $event)"
|
||||||
|
@convert-row-name="$emit('convert-row-name', $event)"
|
||||||
|
@remove-player-image="$emit('remove-player-image', $event)"
|
||||||
|
@delete-player="$emit('delete-player', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section v-show="adminTab === 'liveMode'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('liveMode') }}</h2>
|
||||||
|
<p>{{ t('labels.liveModeVisibility') }}</p>
|
||||||
|
</div>
|
||||||
|
<LiveSettingsCard
|
||||||
|
:t="t"
|
||||||
|
:live-settings="liveSettings"
|
||||||
|
:assignable-groups="assignableGroups"
|
||||||
|
:stage-control="stageControl"
|
||||||
|
:stage-options="stageOptions"
|
||||||
|
:current-stage="currentStage"
|
||||||
|
:last-stage="lastStage"
|
||||||
|
@update-live-setting="$emit('update-live-setting', $event)"
|
||||||
|
@update-stage-control="$emit('update-stage-control', $event)"
|
||||||
|
@start-stage="$emit('start-stage')"
|
||||||
|
@end-stage="$emit('end-stage')"
|
||||||
|
@navigate-current-stage="$emit('navigate-current-stage')"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="adminTab === 'preliminary'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.preliminaryAdminTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.preliminaryAdminSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'preliminary')">{{ t('actions.resetScores') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage-filter-bar">
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
:value="scoreFilters.preliminary"
|
||||||
|
:placeholder="t('actions.searchPlayer')"
|
||||||
|
@input="$emit('update-score-filter', { stage: 'preliminary', value: $event.target.value })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PositionBoardEditor
|
||||||
|
:t="t"
|
||||||
|
board="preliminary"
|
||||||
|
:rows="preliminaryScoreRows"
|
||||||
|
:slots="positionBoards.preliminary"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:display-name="displayName"
|
||||||
|
:secondary-name="secondaryName"
|
||||||
|
:available-groups="assignableGroups"
|
||||||
|
@save="$emit('save-position-board', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MultiRoundScoreEditor
|
||||||
|
:t="t"
|
||||||
|
:rows="preliminaryScoreRows"
|
||||||
|
:round-stages="[
|
||||||
|
{ stage: 'prelim_r1', label: t('table.round1') },
|
||||||
|
{ stage: 'prelim_r2', label: t('table.round2') },
|
||||||
|
{ stage: 'prelim_r3', label: t('table.round3') },
|
||||||
|
]"
|
||||||
|
:total-score="preliminaryTotalScore"
|
||||||
|
:show-filter="false"
|
||||||
|
:filter-text="scoreFilters.preliminary"
|
||||||
|
:section-by-group="true"
|
||||||
|
:overflow-limit="6"
|
||||||
|
:highlight-stage="activeScoringStage"
|
||||||
|
:highlight-group="activeScoringGroup"
|
||||||
|
:show-group="true"
|
||||||
|
:show-rank="false"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:display-name="displayName"
|
||||||
|
:secondary-name="secondaryName"
|
||||||
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
|
:on-score-focus="onScoreFocus"
|
||||||
|
:on-score-input="onScoreInput"
|
||||||
|
:on-score-commit="onScoreCommit"
|
||||||
|
:has-score-proof="hasScoreProof"
|
||||||
|
:score-proof-for="scoreProofFor"
|
||||||
|
:open-score-proof-uploader="openScoreProofUploader"
|
||||||
|
:open-proof-preview="openProofPreview"
|
||||||
|
:show-proof-controls="false"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="adminTab === 'prelimTie'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.prelimTieTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.prelimTieSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint-box" v-if="prelimTie.required">
|
||||||
|
{{ t('labels.tieSlots') }}: {{ prelimTie.slots }}
|
||||||
|
</div>
|
||||||
|
<div class="hint-box danger" v-if="prelimTie.required && !prelimTie.resolved">
|
||||||
|
{{ t('messages.prelimTieUnresolved') }}
|
||||||
|
</div>
|
||||||
|
<div class="empty-state good" v-if="!prelimTie.required">{{ t('messages.noPrelimTie') }}</div>
|
||||||
|
|
||||||
|
<template v-if="prelimTie.required">
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'prelim_tiebreak')">{{ t('actions.resetScores') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PositionBoardEditor
|
||||||
|
:t="t"
|
||||||
|
board="prelim_tiebreak"
|
||||||
|
:rows="prelimTieScoreRows"
|
||||||
|
:slots="positionBoards.prelim_tiebreak"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:display-name="displayName"
|
||||||
|
:secondary-name="secondaryName"
|
||||||
|
:numeric-groups="true"
|
||||||
|
@save="$emit('save-position-board', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScoreStageEditor
|
||||||
|
:t="t"
|
||||||
|
stage="prelim_tiebreak"
|
||||||
|
color-mode="score"
|
||||||
|
:rows="prelimTieScoreRows"
|
||||||
|
:filter-text="scoreFilters.prelim_tiebreak"
|
||||||
|
:section-by-group="true"
|
||||||
|
:tie-break-section-mode="true"
|
||||||
|
:show-score-before-input="true"
|
||||||
|
:input-label="t('table.tieScore')"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:display-name="displayName"
|
||||||
|
:secondary-name="secondaryName"
|
||||||
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
|
:on-score-focus="onScoreFocus"
|
||||||
|
:on-score-input="onScoreInput"
|
||||||
|
:on-score-commit="onScoreCommit"
|
||||||
|
:has-score-proof="hasScoreProof"
|
||||||
|
:score-proof-for="scoreProofFor"
|
||||||
|
:open-score-proof-uploader="openScoreProofUploader"
|
||||||
|
:remove-score-proof="removeScoreProof"
|
||||||
|
:open-proof-preview="openProofPreview"
|
||||||
|
:request-score-advice="requestScoreAdvice"
|
||||||
|
:highlight-stage="activeScoringStage"
|
||||||
|
:highlight-group="activeScoringGroup"
|
||||||
|
:show-proof-controls="false"
|
||||||
|
@update:filter="$emit('update-score-filter', { stage: 'prelim_tiebreak', value: $event })"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="adminTab === 'final'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.finalAdminTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.finalAdminSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final')">{{ t('actions.resetScores') }}</button>
|
||||||
|
<button class="btn btn-outline" @click="$emit('reset-final-group-by-seed')">{{ t('actions.resetFinalGroupBySeed') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage-filter-bar">
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
:value="scoreFilters.final_common"
|
||||||
|
:placeholder="t('actions.searchPlayer')"
|
||||||
|
@input="$emit('update-score-filter', { stage: 'final_common', value: $event.target.value })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PositionBoardEditor
|
||||||
|
:t="t"
|
||||||
|
board="final"
|
||||||
|
:rows="finalScoreRows"
|
||||||
|
:slots="positionBoards.final"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:display-name="displayName"
|
||||||
|
:secondary-name="secondaryName"
|
||||||
|
:numeric-groups="true"
|
||||||
|
@save="$emit('save-position-board', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MultiRoundScoreEditor
|
||||||
|
:t="t"
|
||||||
|
:rows="finalScoreRows"
|
||||||
|
:round-stages="[
|
||||||
|
{ stage: 'final_r1', label: t('table.round1') },
|
||||||
|
{ stage: 'final_r2', label: t('table.round2') },
|
||||||
|
]"
|
||||||
|
:total-score="finalTotalScore"
|
||||||
|
:show-filter="false"
|
||||||
|
:filter-text="scoreFilters.final_common"
|
||||||
|
:section-by-group="true"
|
||||||
|
:overflow-limit="6"
|
||||||
|
:highlight-stage="activeScoringStage"
|
||||||
|
:highlight-group="activeScoringGroup"
|
||||||
|
:show-group="true"
|
||||||
|
:show-seed="true"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:display-name="displayName"
|
||||||
|
:secondary-name="secondaryName"
|
||||||
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
|
:on-score-focus="onScoreFocus"
|
||||||
|
:on-score-input="onScoreInput"
|
||||||
|
:on-score-commit="onScoreCommit"
|
||||||
|
:has-score-proof="hasScoreProof"
|
||||||
|
:score-proof-for="scoreProofFor"
|
||||||
|
:open-score-proof-uploader="openScoreProofUploader"
|
||||||
|
:open-proof-preview="openProofPreview"
|
||||||
|
:show-proof-controls="false"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="adminTab === 'finalTie'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.finalTieTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.finalTieSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint-box danger" v-if="finalTie.required && !finalTie.resolved">{{ t('messages.finalTieUnresolved') }}</div>
|
||||||
|
<div class="empty-state good" v-if="!finalTie.required">{{ t('messages.noFinalTie') }}</div>
|
||||||
|
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final_tiebreak')">{{ t('actions.resetScores') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="finalTie.required">
|
||||||
|
<PositionBoardEditor
|
||||||
|
:t="t"
|
||||||
|
board="final_tiebreak"
|
||||||
|
:rows="finalTieScoreRows"
|
||||||
|
:slots="positionBoards.final_tiebreak"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:display-name="displayName"
|
||||||
|
:secondary-name="secondaryName"
|
||||||
|
:numeric-groups="true"
|
||||||
|
@save="$emit('save-position-board', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScoreStageEditor
|
||||||
|
:t="t"
|
||||||
|
stage="final_tiebreak"
|
||||||
|
color-mode="score"
|
||||||
|
:rows="finalTieScoreRows"
|
||||||
|
:filter-text="scoreFilters.final_tiebreak"
|
||||||
|
:section-by-group="true"
|
||||||
|
:tie-break-section-mode="true"
|
||||||
|
:show-score-before-input="true"
|
||||||
|
:input-label="t('table.tieScore')"
|
||||||
|
:player-image="playerImage"
|
||||||
|
:display-name="displayName"
|
||||||
|
:secondary-name="secondaryName"
|
||||||
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
|
:on-score-focus="onScoreFocus"
|
||||||
|
:on-score-input="onScoreInput"
|
||||||
|
:on-score-commit="onScoreCommit"
|
||||||
|
:has-score-proof="hasScoreProof"
|
||||||
|
:score-proof-for="scoreProofFor"
|
||||||
|
:open-score-proof-uploader="openScoreProofUploader"
|
||||||
|
:remove-score-proof="removeScoreProof"
|
||||||
|
:open-proof-preview="openProofPreview"
|
||||||
|
:request-score-advice="requestScoreAdvice"
|
||||||
|
:highlight-stage="activeScoringStage"
|
||||||
|
:highlight-group="activeScoringGroup"
|
||||||
|
:show-proof-controls="false"
|
||||||
|
@update:filter="$emit('update-score-filter', { stage: 'final_tiebreak', value: $event })"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<h3 class="sub-heading mt-32">{{ t('sections.finalRanking') }}</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.rank') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.score') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
<th>{{ t('table.medal') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in finalRows" :key="'a-fr-' + row.playerId" :class="{ 'podium-row': row.rank <= 3 }">
|
||||||
|
<td class="mono rank">{{ row.rank }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">{{ row.score }}</td>
|
||||||
|
<td class="mono">{{ row.tieBreak }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="row.rank === 1">🥇</span>
|
||||||
|
<span v-else-if="row.rank === 2">🥈</span>
|
||||||
|
<span v-else-if="row.rank === 3">🥉</span>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="finalRows.length === 0"><td colspan="5" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import PlayersManagementTab from './admin/PlayersManagementTab.vue'
|
||||||
|
import ScoreStageEditor from './admin/ScoreStageEditor.vue'
|
||||||
|
import LiveSettingsCard from './admin/LiveSettingsCard.vue'
|
||||||
|
import MultiRoundScoreEditor from './admin/MultiRoundScoreEditor.vue'
|
||||||
|
import PositionBoardEditor from './admin/PositionBoardEditor.vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
adminTabs: { type: Array, required: true },
|
||||||
|
adminTab: { type: String, required: true },
|
||||||
|
viewProofInView: { type: Boolean, default: false },
|
||||||
|
liveSettings: { type: Object, required: true },
|
||||||
|
stageControl: { type: Object, required: true },
|
||||||
|
stageOptions: { type: Object, required: true },
|
||||||
|
currentStage: { type: Object, default: null },
|
||||||
|
lastStage: { type: Object, default: null },
|
||||||
|
activeScoringStage: { type: String, default: '' },
|
||||||
|
activeScoringGroup: { type: String, default: '' },
|
||||||
|
groupSetupInput: { type: String, default: '' },
|
||||||
|
adminGroupCards: { type: Array, required: true },
|
||||||
|
newPlayer: { type: Object, required: true },
|
||||||
|
assignableGroups: { type: Array, required: true },
|
||||||
|
playersSorted: { type: Array, required: true },
|
||||||
|
playerFilter: { type: String, default: '' },
|
||||||
|
playerSort: { type: String, default: 'id' },
|
||||||
|
playerImage: { type: Function, required: true },
|
||||||
|
groupOptionLabel: { type: Function, required: true },
|
||||||
|
normalizedGroupCode: { type: Function, required: true },
|
||||||
|
preliminaryScoreRows: { type: Array, required: true },
|
||||||
|
prelimTieScoreRows: { type: Array, required: true },
|
||||||
|
finalScoreRows: { type: Array, required: true },
|
||||||
|
finalTieScoreRows: { type: Array, required: true },
|
||||||
|
finalRows: { type: Array, required: true },
|
||||||
|
preliminaryTotalScore: { type: Function, required: true },
|
||||||
|
finalTotalScore: { type: Function, required: true },
|
||||||
|
prelimTie: { type: Object, required: true },
|
||||||
|
finalTie: { type: Object, required: true },
|
||||||
|
positionBoards: { type: Object, required: true },
|
||||||
|
scoreFilters: { type: Object, required: true },
|
||||||
|
displayName: { type: Function, required: true },
|
||||||
|
secondaryName: { type: Function, required: true },
|
||||||
|
scoreInputValue: { type: Function, required: true },
|
||||||
|
scoreInputInvalid: { type: Function, required: true },
|
||||||
|
onScoreFocus: { type: Function, required: true },
|
||||||
|
onScoreInput: { type: Function, required: true },
|
||||||
|
onScoreCommit: { type: Function, required: true },
|
||||||
|
hasScoreProof: { type: Function, required: true },
|
||||||
|
scoreProofFor: { type: Function, required: true },
|
||||||
|
openScoreProofUploader: { type: Function, required: true },
|
||||||
|
removeScoreProof: { type: Function, required: true },
|
||||||
|
openProofPreview: { type: Function, required: true },
|
||||||
|
requestScoreAdvice: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits([
|
||||||
|
'refresh',
|
||||||
|
'logout',
|
||||||
|
'toggle-view-proof',
|
||||||
|
'update-live-setting',
|
||||||
|
'update-stage-control',
|
||||||
|
'start-stage',
|
||||||
|
'end-stage',
|
||||||
|
'navigate-current-stage',
|
||||||
|
'change-admin-tab',
|
||||||
|
'update:group-setup-input',
|
||||||
|
'save-group-setup',
|
||||||
|
'auto-group-even',
|
||||||
|
'update:new-player',
|
||||||
|
'create-player',
|
||||||
|
'convert-new-name',
|
||||||
|
'update:player-filter',
|
||||||
|
'update:player-sort',
|
||||||
|
'open-image-uploader',
|
||||||
|
'update-player-field',
|
||||||
|
'update-player-group',
|
||||||
|
'convert-row-name',
|
||||||
|
'remove-player-image',
|
||||||
|
'delete-player',
|
||||||
|
'request-reset-stage',
|
||||||
|
'reset-final-group-by-seed',
|
||||||
|
'save-position-board',
|
||||||
|
'update-score-filter',
|
||||||
|
])
|
||||||
|
</script>
|
||||||
148
frontend/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<header class="masthead">
|
||||||
|
<div class="masthead-main">
|
||||||
|
<div class="masthead-brand">
|
||||||
|
<picture>
|
||||||
|
<source v-if="mode !== 'live'" srcset="/logo.svg" type="image/svg+xml" />
|
||||||
|
<img class="masthead-logo" :src="mode === 'live' ? '/logo_white.png' : '/logo.png'" :alt="competitionTitle" />
|
||||||
|
</picture>
|
||||||
|
<p class="masthead-subtitle">{{ t('subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="masthead-controls">
|
||||||
|
<div ref="controlsRoot" class="controls-popup-anchor">
|
||||||
|
<div class="control-toggle-row">
|
||||||
|
<button class="control-toggle-btn" type="button" :aria-expanded="controlsOpen ? 'true' : 'false'" @click="toggleControls">
|
||||||
|
<span>{{ optionsLabel }}</span>
|
||||||
|
<span class="control-toggle-meta">{{ modeShort }} · {{ languageShort }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="control-icon-btn"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: isFullscreen }"
|
||||||
|
:aria-label="fullscreenActionLabel"
|
||||||
|
:title="fullscreenActionLabel"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
v-if="!isFullscreen"
|
||||||
|
d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-else
|
||||||
|
d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="controls-reveal">
|
||||||
|
<div v-if="controlsOpen" class="controls-grid controls-popover">
|
||||||
|
<div class="control-box">
|
||||||
|
<p class="control-label">{{ t('labels.mode') }}</p>
|
||||||
|
<div class="language-switcher mode-switcher">
|
||||||
|
<!-- <button class="lang-btn" :class="{ active: mode === 'view' }" @click="changeMode('view')">{{ t('viewMode') }}</button> -->
|
||||||
|
<button class="lang-btn" :class="{ active: mode === 'live' }" @click="changeMode('live')">{{ t('liveMode') }}</button>
|
||||||
|
<button class="lang-btn" :class="{ active: mode === 'admin' }" @click="changeMode('admin')">{{ t('adminMode') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-box">
|
||||||
|
<p class="control-label">{{ t('labels.language') }}</p>
|
||||||
|
<div class="language-switcher">
|
||||||
|
<button class="lang-btn" :class="{ active: language === 'ar' }" @click="changeLanguage('ar')">العربية</button>
|
||||||
|
<button class="lang-btn" :class="{ active: language === 'en' }" @click="changeLanguage('en')">English</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
<div class="status-row">
|
||||||
|
<div class="live-badge">{{ modeStatusLabel }}</div>
|
||||||
|
<div class="server-time" v-if="serverTime">{{ t('labels.lastSync') }}: {{ serverTime }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
competitionTitle: { type: String, required: true },
|
||||||
|
mode: { type: String, required: true },
|
||||||
|
language: { type: String, required: true },
|
||||||
|
serverTime: { type: String, default: '' },
|
||||||
|
isFullscreen: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['change-mode', 'change-language', 'toggle-fullscreen'])
|
||||||
|
|
||||||
|
const controlsOpen = ref(false)
|
||||||
|
const controlsRoot = ref(null)
|
||||||
|
|
||||||
|
const optionsLabel = computed(() => (props.language === 'ar' ? 'خيارات' : 'Options'))
|
||||||
|
const modeShort = computed(() => {
|
||||||
|
if (props.mode === 'view') return props.language === 'ar' ? 'عرض' : 'View'
|
||||||
|
if (props.mode === 'live') return props.language === 'ar' ? 'حي' : 'Live'
|
||||||
|
return props.language === 'ar' ? 'إدارة' : 'Admin'
|
||||||
|
})
|
||||||
|
const languageShort = computed(() => (props.language === 'ar' ? 'AR' : 'EN'))
|
||||||
|
const fullscreenActionLabel = computed(() => (props.isFullscreen ? props.t('actions.exitFullscreen') : props.t('actions.enterFullscreen')))
|
||||||
|
const modeStatusLabel = computed(() => {
|
||||||
|
if (props.mode === 'view') return '● ' + props.t('labels.liveTracker')
|
||||||
|
if (props.mode === 'live') return '● ' + props.t('liveMode')
|
||||||
|
return props.t('adminPanel')
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleControls() {
|
||||||
|
controlsOpen.value = !controlsOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeMode(nextMode) {
|
||||||
|
emit('change-mode', nextMode)
|
||||||
|
controlsOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeLanguage(nextLanguage) {
|
||||||
|
emit('change-language', nextLanguage)
|
||||||
|
controlsOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFullscreen() {
|
||||||
|
emit('toggle-fullscreen')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalPointerDown(event) {
|
||||||
|
if (!controlsOpen.value) return
|
||||||
|
const root = controlsRoot.value
|
||||||
|
if (root && !root.contains(event.target)) controlsOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalKeyDown(event) {
|
||||||
|
if (event.key === 'Escape') controlsOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('pointerdown', onGlobalPointerDown)
|
||||||
|
window.addEventListener('keydown', onGlobalKeyDown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('pointerdown', onGlobalPointerDown)
|
||||||
|
window.removeEventListener('keydown', onGlobalKeyDown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
190
frontend/src/components/ImageCropModal.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="modal-card image-crop-modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>{{ t('sections.profileCropTitle') }}</h3>
|
||||||
|
<button class="btn btn-outline btn-xs" @click="$emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="modal-text subtle">{{ t('sections.profileCropSubtitle') }}</p>
|
||||||
|
|
||||||
|
<div class="crop-canvas-wrap">
|
||||||
|
<canvas
|
||||||
|
ref="canvasRef"
|
||||||
|
class="crop-canvas"
|
||||||
|
width="340"
|
||||||
|
height="340"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
@pointercancel="onPointerUp"
|
||||||
|
/>
|
||||||
|
<div class="crop-circle-guide" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="crop-controls">
|
||||||
|
<label>{{ t('labels.zoom') }}</label>
|
||||||
|
<input type="range" min="1" max="3" step="0.01" :value="zoom" @input="onZoomInput" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions split">
|
||||||
|
<button class="btn btn-outline" @click="resetPosition">{{ t('actions.resetPosition') }}</button>
|
||||||
|
<button class="btn btn-secondary" @click="$emit('close')">{{ t('actions.cancel') }}</button>
|
||||||
|
<button class="btn btn-primary" @click="confirmCrop">{{ t('actions.applyCrop') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
sourceImage: { type: String, default: '' },
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'confirm'])
|
||||||
|
|
||||||
|
const canvasRef = ref(null)
|
||||||
|
const zoom = ref(1)
|
||||||
|
const image = new Image()
|
||||||
|
const state = reactive({
|
||||||
|
loaded: false,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
dragging: false,
|
||||||
|
pointerId: null,
|
||||||
|
lastX: 0,
|
||||||
|
lastY: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const CANVAS_SIZE = 340
|
||||||
|
const EXPORT_SIZE = 520
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.open, props.sourceImage],
|
||||||
|
async ([open, src]) => {
|
||||||
|
if (!open || !src) return
|
||||||
|
await loadImage(src)
|
||||||
|
resetPosition()
|
||||||
|
drawPreview()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(zoom, () => {
|
||||||
|
clampOffsets()
|
||||||
|
drawPreview()
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
drawPreview()
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadImage(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
image.onload = () => {
|
||||||
|
state.loaded = true
|
||||||
|
state.width = image.naturalWidth || image.width
|
||||||
|
state.height = image.naturalHeight || image.height
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
image.onerror = () => reject(new Error('failed to load image'))
|
||||||
|
image.src = src
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onZoomInput(event) {
|
||||||
|
zoom.value = Number(event.target.value || 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPosition() {
|
||||||
|
zoom.value = 1
|
||||||
|
state.offsetX = 0
|
||||||
|
state.offsetY = 0
|
||||||
|
drawPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDrawMetrics(size) {
|
||||||
|
const baseScale = Math.max(size / state.width, size / state.height)
|
||||||
|
const scale = baseScale * zoom.value
|
||||||
|
const width = state.width * scale
|
||||||
|
const height = state.height * scale
|
||||||
|
const left = (size-width)/2 + state.offsetX
|
||||||
|
const top = (size-height)/2 + state.offsetY
|
||||||
|
return { width, height, left, top }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampOffsets() {
|
||||||
|
if (!state.loaded) return
|
||||||
|
const { width, height } = getDrawMetrics(CANVAS_SIZE)
|
||||||
|
const maxOffsetX = Math.max(0, (width - CANVAS_SIZE) / 2)
|
||||||
|
const maxOffsetY = Math.max(0, (height - CANVAS_SIZE) / 2)
|
||||||
|
if (state.offsetX > maxOffsetX) state.offsetX = maxOffsetX
|
||||||
|
if (state.offsetX < -maxOffsetX) state.offsetX = -maxOffsetX
|
||||||
|
if (state.offsetY > maxOffsetY) state.offsetY = maxOffsetY
|
||||||
|
if (state.offsetY < -maxOffsetY) state.offsetY = -maxOffsetY
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPreview() {
|
||||||
|
const canvas = canvasRef.value
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE)
|
||||||
|
ctx.fillStyle = '#f0f2f8'
|
||||||
|
ctx.fillRect(0, 0, CANVAS_SIZE, CANVAS_SIZE)
|
||||||
|
|
||||||
|
if (!state.loaded) return
|
||||||
|
const { width, height, left, top } = getDrawMetrics(CANVAS_SIZE)
|
||||||
|
ctx.drawImage(image, left, top, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event) {
|
||||||
|
if (!state.loaded) return
|
||||||
|
state.dragging = true
|
||||||
|
state.pointerId = event.pointerId
|
||||||
|
state.lastX = event.clientX
|
||||||
|
state.lastY = event.clientY
|
||||||
|
event.target.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event) {
|
||||||
|
if (!state.dragging || state.pointerId !== event.pointerId) return
|
||||||
|
const dx = event.clientX - state.lastX
|
||||||
|
const dy = event.clientY - state.lastY
|
||||||
|
state.lastX = event.clientX
|
||||||
|
state.lastY = event.clientY
|
||||||
|
state.offsetX += dx
|
||||||
|
state.offsetY += dy
|
||||||
|
clampOffsets()
|
||||||
|
drawPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(event) {
|
||||||
|
if (state.pointerId !== event.pointerId) return
|
||||||
|
state.dragging = false
|
||||||
|
state.pointerId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCrop() {
|
||||||
|
if (!state.loaded) return
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = EXPORT_SIZE
|
||||||
|
canvas.height = EXPORT_SIZE
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const { width, height, left, top } = getDrawMetrics(EXPORT_SIZE)
|
||||||
|
ctx.fillStyle = '#f0f2f8'
|
||||||
|
ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE)
|
||||||
|
ctx.drawImage(image, left, top, width, height)
|
||||||
|
emit('confirm', canvas.toDataURL('image/jpeg', 0.9))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
639
frontend/src/components/LiveModePanel.vue
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
<template>
|
||||||
|
<div class="live-mode-root">
|
||||||
|
<section class="live-mode-shell">
|
||||||
|
<div class="live-mode-overlay">
|
||||||
|
<!-- <div class="live-screen-head live-head-selection">
|
||||||
|
<span class="live-chip strong">{{ activeViewLabel }}</span>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<div class="live-mode-grid single">
|
||||||
|
<article v-if="activeView === 'group_live'" class="live-screen-card wide">
|
||||||
|
<div class="live-screen-head">
|
||||||
|
<h3>{{ t('sections.liveTitle') }}</h3>
|
||||||
|
<div class="live-chip-row">
|
||||||
|
<span class="live-chip">{{ liveSettings.groupDisplayMode === 'fixed' ? t('actions.liveModeFixed') : t('actions.liveModeRotate') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="live-swap" mode="out-in">
|
||||||
|
<div :key="'live-group-swap-' + (liveGroupCode || 'u')" class="live-swap-block">
|
||||||
|
<div class="live-focus-banner" :class="'focus-' + groupClassKey">
|
||||||
|
<span class="live-focus-label">{{ t('labels.group') }}</span>
|
||||||
|
<strong class="live-focus-title">{{ liveGroupCode || t('labels.unassigned') }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table live-score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.position') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.round1') }}</th>
|
||||||
|
<th>{{ t('table.round2') }}</th>
|
||||||
|
<th>{{ t('table.round3') }}</th>
|
||||||
|
<th>{{ t('table.total') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(entry, index) in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id">
|
||||||
|
<td class="mono rank">{{ index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(entry.player)" :alt="entry.player.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(entry.player) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ scoreFor('prelim_r1', entry.player.id) }}</td>
|
||||||
|
<td class="mono">{{ scoreFor('prelim_r2', entry.player.id) }}</td>
|
||||||
|
<td class="mono">{{ scoreFor('prelim_r3', entry.player.id) }}</td>
|
||||||
|
<td class="mono strong">{{ entry.total }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="liveGroupMembers.length === 0"><td colspan="6" class="muted center">{{ t('labels.emptyLive') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-else-if="activeView === 'prelim_tie'" class="live-screen-card wide">
|
||||||
|
<div class="live-screen-head">
|
||||||
|
<h3>{{ t('sections.prelimTieTitle') }}</h3>
|
||||||
|
<div class="live-chip-row">
|
||||||
|
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
||||||
|
<span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="live-swap" mode="out-in">
|
||||||
|
<article v-if="activePrelimTieGroup" :key="'live-pre-tie-group-' + activePrelimTieGroup.groupKey" class="live-tie-group-card">
|
||||||
|
<h4 class="live-tie-group-title">{{ t('labels.group') }} {{ activePrelimTieGroup.groupKey }}</h4>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.position') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="entry in activePrelimTieGroup.rows" :key="'live-pre-tie-' + activePrelimTieGroup.groupKey + '-' + entry.row.playerId">
|
||||||
|
<td class="mono">{{ entry.position }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(entry.row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">{{ entry.row.tieBreak }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Transition>
|
||||||
|
<div v-if="groupedPrelimTieRows.length === 0" class="muted center">{{ t('messages.noPrelimTie') }}</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-else-if="activeView === 'prelim_overall'" class="live-screen-card wide">
|
||||||
|
<div class="live-screen-head">
|
||||||
|
<h3>{{ t('sections.overallTitle') }}</h3>
|
||||||
|
<div class="live-chip-row">
|
||||||
|
<span class="live-chip strong">{{ t('labels.top12') }}: {{ finalists.length }}</span>
|
||||||
|
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="live-swap" mode="out-in">
|
||||||
|
<div :key="'prelim-overall-page-' + listPageIndex" class="live-swap-block">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.rank') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.total') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in pagedPreliminaryRows"
|
||||||
|
:key="'live-pre-overall-' + row.playerId"
|
||||||
|
>
|
||||||
|
<td class="mono rank">
|
||||||
|
<div class="live-rank-cell">
|
||||||
|
<span>{{ row.rank }}</span>
|
||||||
|
<span v-if="prelimOverallHintLabel(row)" class="live-rank-hint">{{ prelimOverallHintLabel(row) }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">{{ row.score }}</td>
|
||||||
|
<td class="mono">{{ row.tieBreak }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="preliminaryRowsForLive.length === 0"><td colspan="4" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-else-if="activeView === 'final_groups'" class="live-screen-card wide">
|
||||||
|
<div class="live-screen-head">
|
||||||
|
<h3>{{ t('sections.finalTitle') }}</h3>
|
||||||
|
<div class="live-chip-row">
|
||||||
|
<span class="live-chip">{{ liveSettings.finalDisplayMode === 'fixed' ? t('actions.liveModeFixed') : t('actions.liveModeRotate') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="live-swap" mode="out-in">
|
||||||
|
<div :key="'live-final-swap-' + liveFinalGroup" class="live-swap-block">
|
||||||
|
<div class="live-focus-banner focus-final">
|
||||||
|
<span class="live-focus-label">{{ currentFinalSeedLabel }}</span>
|
||||||
|
<strong class="live-focus-title">{{ currentFinalGroupNumber }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table live-score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.position') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.round1') }}</th>
|
||||||
|
<th>{{ t('table.round2') }}</th>
|
||||||
|
<th>{{ t('table.total') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(entry, index) in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId">
|
||||||
|
<td class="mono rank">{{ index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(entry.row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ scoreFor('final_r1', entry.row.playerId) }}</td>
|
||||||
|
<td class="mono">{{ scoreFor('final_r2', entry.row.playerId) }}</td>
|
||||||
|
<td class="mono strong">{{ entry.total }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="liveFinalRows.length === 0"><td colspan="5" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-else-if="activeView === 'final_tie'" class="live-screen-card wide">
|
||||||
|
<div class="live-screen-head">
|
||||||
|
<h3>{{ t('sections.finalTieTitle') }}</h3>
|
||||||
|
<div class="live-chip-row">
|
||||||
|
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
||||||
|
<span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="live-swap" mode="out-in">
|
||||||
|
<article v-if="activeFinalTieGroup" :key="'live-final-tie-group-' + activeFinalTieGroup.groupKey" class="live-tie-group-card">
|
||||||
|
<h4 class="live-tie-group-title">{{ t('labels.group') }} {{ activeFinalTieGroup.groupKey }}</h4>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.position') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="entry in activeFinalTieGroup.rows" :key="'live-final-tie-' + activeFinalTieGroup.groupKey + '-' + entry.row.playerId">
|
||||||
|
<td class="mono">{{ entry.position }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(entry.row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">{{ entry.row.tieBreak }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</Transition>
|
||||||
|
<div v-if="groupedFinalTieRows.length === 0" class="muted center">{{ t('messages.noFinalTie') }}</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-else-if="activeView === 'final_overall'" class="live-screen-card wide">
|
||||||
|
<div class="live-screen-head">
|
||||||
|
<h3>{{ t('sections.finalRanking') }}</h3>
|
||||||
|
<div class="live-chip-row">
|
||||||
|
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition name="live-swap" mode="out-in">
|
||||||
|
<div :key="'final-overall-page-' + listPageIndex" class="live-swap-block">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.rank') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.score') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in pagedFinalOverallRows" :key="'live-final-overall-' + row.playerId">
|
||||||
|
<td class="mono rank">
|
||||||
|
<div class="live-rank-cell">
|
||||||
|
<span>{{ row.rank }}</span>
|
||||||
|
<span v-if="finalOverallHintLabel(row)" class="live-rank-hint">{{ finalOverallHintLabel(row) }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">{{ row.score }}</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
<tr v-if="finalOverallRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article v-else class="live-screen-card podium-live-card wide">
|
||||||
|
<div class="live-screen-head">
|
||||||
|
<h3>{{ t('sections.podiumTitle') }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="podium-wrapper live-podium">
|
||||||
|
<article class="podium-col pos-2">
|
||||||
|
<div class="podium-avatar-wrap">
|
||||||
|
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
|
||||||
|
</div>
|
||||||
|
<div class="podium-medal-icon">🥈</div>
|
||||||
|
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[0])" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
|
||||||
|
<div class="podium-score-wrap">
|
||||||
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
|
||||||
|
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="podium-col pos-1">
|
||||||
|
<div class="podium-avatar-wrap">
|
||||||
|
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
|
||||||
|
</div>
|
||||||
|
<div class="podium-medal-icon">🥇</div>
|
||||||
|
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[1])" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
|
||||||
|
<div class="podium-score-wrap">
|
||||||
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
|
||||||
|
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="podium-col pos-3">
|
||||||
|
<div class="podium-avatar-wrap">
|
||||||
|
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
|
||||||
|
</div>
|
||||||
|
<div class="podium-medal-icon">🥉</div>
|
||||||
|
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[2])" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
|
||||||
|
<div class="podium-score-wrap">
|
||||||
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
|
||||||
|
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="live-mode-footer">
|
||||||
|
|
||||||
|
<div class="live-footer-slot left ">
|
||||||
|
<img src="/datwyler_logo.png" alt="Datwyler logo" class=" live-footer-logo live-footer-logo-datwyler " />
|
||||||
|
</div>
|
||||||
|
<div class="live-footer-slot center">
|
||||||
|
<img src="/arture.png" alt="Arture logo" class="live-footer-logo" />
|
||||||
|
</div>
|
||||||
|
<div class="live-footer-slot right">
|
||||||
|
<img src="/simon_logo.png" alt="Simon logo" class="live-footer-logo" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
liveSettings: { type: Object, required: true },
|
||||||
|
positionBoards: { type: Object, default: () => ({}) },
|
||||||
|
liveGroupCode: { type: String, default: '' },
|
||||||
|
liveGroupMembers: { type: Array, required: true },
|
||||||
|
prelimTie: { type: Object, default: () => ({ required: false, resolved: true, slots: 0, playerIds: [] }) },
|
||||||
|
prelimTieRows: { type: Array, required: true },
|
||||||
|
preliminaryRows: { type: Array, required: true },
|
||||||
|
finalists: { type: Array, required: true },
|
||||||
|
liveFinalGroup: { type: Number, required: true },
|
||||||
|
liveFinalRows: { type: Array, required: true },
|
||||||
|
finalOverallRows: { type: Array, required: true },
|
||||||
|
finalTieRows: { type: Array, required: true },
|
||||||
|
podiumOrdered: { type: Array, required: true },
|
||||||
|
playerImage: { type: Function, required: true },
|
||||||
|
displayName: { type: Function, required: true },
|
||||||
|
scoreFor: { type: Function, required: true },
|
||||||
|
prelimTotal: { type: Function, required: true },
|
||||||
|
finalTotal: { type: Function, required: true },
|
||||||
|
podiumImage: { type: Function, required: true },
|
||||||
|
podiumName: { type: Function, required: true },
|
||||||
|
podiumHasResult: { type: Function, required: true },
|
||||||
|
podiumScoreMain: { type: Function, required: true },
|
||||||
|
podiumTieSubtitle: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalistIds = computed(() => new Set((props.finalists || []).map((row) => row.playerId)))
|
||||||
|
const prelimTieIds = computed(() => new Set((props.prelimTieRows || []).map((row) => row.playerId)))
|
||||||
|
const finalTieIds = computed(() => new Set((props.finalTieRows || []).map((row) => row.playerId)))
|
||||||
|
const prelimTieResolved = computed(() => Boolean(props.prelimTie?.resolved))
|
||||||
|
const activeView = computed(() => props.liveSettings.activeView || 'group_live')
|
||||||
|
const listPageIndex = ref(0)
|
||||||
|
const tieGroupIndex = ref(0)
|
||||||
|
let listRotationTimer = null
|
||||||
|
let tieRotationTimer = null
|
||||||
|
|
||||||
|
const rotationIntervalSec = computed(() => {
|
||||||
|
const raw = Number(props.liveSettings.rotationIntervalSec || 5)
|
||||||
|
if (!Number.isFinite(raw)) return 5
|
||||||
|
return Math.max(3, raw)
|
||||||
|
})
|
||||||
|
|
||||||
|
const rotationPlayerCount = computed(() => {
|
||||||
|
const raw = Number(props.liveSettings.rotationPlayerCount || 12)
|
||||||
|
if (!Number.isFinite(raw)) return 12
|
||||||
|
if (raw < 3) return 3
|
||||||
|
if (raw > 40) return 40
|
||||||
|
return Math.floor(raw)
|
||||||
|
})
|
||||||
|
|
||||||
|
const preliminaryRowsForLive = computed(() => rankPreliminaryRowsWithTieBreak(props.preliminaryRows || []))
|
||||||
|
|
||||||
|
const listRowsForActiveView = computed(() => {
|
||||||
|
if (activeView.value === 'prelim_overall') return preliminaryRowsForLive.value
|
||||||
|
if (activeView.value === 'final_overall') return props.finalOverallRows || []
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const listPageCount = computed(() => {
|
||||||
|
const total = listRowsForActiveView.value.length
|
||||||
|
if (total <= 0) return 1
|
||||||
|
return Math.max(1, Math.ceil(total / rotationPlayerCount.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentListPageDisplay = computed(() => `${listPageIndex.value + 1} / ${listPageCount.value}`)
|
||||||
|
|
||||||
|
const pagedPreliminaryRows = computed(() => paginateRows(preliminaryRowsForLive.value))
|
||||||
|
const pagedFinalOverallRows = computed(() => paginateRows(props.finalOverallRows || []))
|
||||||
|
const groupedPrelimTieRows = computed(() => groupTieRowsByPositionBoard('prelim_tiebreak', props.prelimTieRows || []))
|
||||||
|
const groupedFinalTieRows = computed(() => groupTieRowsByPositionBoard('final_tiebreak', props.finalTieRows || []))
|
||||||
|
const tieGroupCountForActiveView = computed(() => {
|
||||||
|
if (activeView.value === 'prelim_tie') return groupedPrelimTieRows.value.length
|
||||||
|
if (activeView.value === 'final_tie') return groupedFinalTieRows.value.length
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
const currentTieGroupPageDisplay = computed(() => {
|
||||||
|
const total = tieGroupCountForActiveView.value
|
||||||
|
if (total <= 0) return '0 / 0'
|
||||||
|
return `${(tieGroupIndex.value % total) + 1} / ${total}`
|
||||||
|
})
|
||||||
|
const activePrelimTieGroup = computed(() => {
|
||||||
|
const groups = groupedPrelimTieRows.value
|
||||||
|
if (groups.length === 0) return null
|
||||||
|
return groups[tieGroupIndex.value % groups.length]
|
||||||
|
})
|
||||||
|
const activeFinalTieGroup = computed(() => {
|
||||||
|
const groups = groupedFinalTieRows.value
|
||||||
|
if (groups.length === 0) return null
|
||||||
|
return groups[tieGroupIndex.value % groups.length]
|
||||||
|
})
|
||||||
|
const rankedLiveGroupMembers = computed(() => {
|
||||||
|
return (props.liveGroupMembers || []).map((player) => ({
|
||||||
|
player,
|
||||||
|
total: Number(props.prelimTotal(player.id) || 0),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeViewLabel = computed(() => {
|
||||||
|
const map = {
|
||||||
|
group_live: props.t('labels.liveViewGroup'),
|
||||||
|
prelim_tie: props.t('labels.liveViewPrelimTie'),
|
||||||
|
prelim_overall: props.t('labels.liveViewPrelimOverall'),
|
||||||
|
final_groups: props.t('labels.liveViewFinalGroups'),
|
||||||
|
final_tie: props.t('labels.liveViewFinalTie'),
|
||||||
|
final_overall: props.t('labels.liveViewFinalOverall'),
|
||||||
|
podium: props.t('labels.liveViewPodium'),
|
||||||
|
}
|
||||||
|
return map[activeView.value] || map.group_live
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentFinalSeedLabel = computed(() => {
|
||||||
|
if (props.liveFinalGroup === 2) return props.t('labels.finalGroupSeeds2')
|
||||||
|
return props.t('labels.finalGroupSeeds1')
|
||||||
|
})
|
||||||
|
const currentFinalGroupNumber = computed(() => (props.liveFinalGroup === 2 ? '2' : '1'))
|
||||||
|
const groupClassKey = computed(() => {
|
||||||
|
const code = String(props.liveGroupCode || '').trim().toUpperCase()
|
||||||
|
if (code.startsWith('A')) return 'a'
|
||||||
|
if (code.startsWith('B')) return 'b'
|
||||||
|
if (code.startsWith('C')) return 'c'
|
||||||
|
if (code.startsWith('D')) return 'd'
|
||||||
|
return 'u'
|
||||||
|
})
|
||||||
|
const rankedLiveFinalRows = computed(() => {
|
||||||
|
return (props.liveFinalRows || []).map((row) => ({
|
||||||
|
row,
|
||||||
|
total: Number(props.finalTotal(row.playerId) || 0),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function isFinalist(playerId) {
|
||||||
|
return finalistIds.value.has(playerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrelimTiePlayer(playerId) {
|
||||||
|
return prelimTieIds.value.has(playerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFinalTiePlayer(playerId) {
|
||||||
|
return finalTieIds.value.has(playerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function prelimOverallHintLabel(row) {
|
||||||
|
if (!prelimTieResolved.value && isPrelimTiePlayer(row.playerId)) return props.t('labels.tieBreak')
|
||||||
|
if (isFinalist(row.playerId)) return props.t('labels.finalist')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalOverallHintLabel(row) {
|
||||||
|
if (isFinalTiePlayer(row.playerId)) return props.t('labels.tieBreak')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() =>
|
||||||
|
`${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalOverallRows.length}|${props.finalTieRows.length}`,
|
||||||
|
() => {
|
||||||
|
restartListRotation()
|
||||||
|
restartTieRotation()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopListRotation()
|
||||||
|
stopTieRotation()
|
||||||
|
})
|
||||||
|
|
||||||
|
function paginateRows(rows) {
|
||||||
|
const start = listPageIndex.value * rotationPlayerCount.value
|
||||||
|
return rows.slice(start, start + rotationPlayerCount.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankPreliminaryRowsWithTieBreak(rows) {
|
||||||
|
const sorted = [...(rows || [])].sort((a, b) => {
|
||||||
|
const scoreA = Number(a.score || 0)
|
||||||
|
const scoreB = Number(b.score || 0)
|
||||||
|
if (scoreA !== scoreB) return scoreB - scoreA
|
||||||
|
const tieA = Number(a.tieBreak || 0)
|
||||||
|
const tieB = Number(b.tieBreak || 0)
|
||||||
|
if (tieA !== tieB) return tieB - tieA
|
||||||
|
return Number(a.playerId || 0) - Number(b.playerId || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
let currentRank = 1
|
||||||
|
return sorted.map((row, index) => {
|
||||||
|
if (index > 0) {
|
||||||
|
const prev = sorted[index - 1]
|
||||||
|
const isSameScore = Number(prev.score || 0) === Number(row.score || 0)
|
||||||
|
const isSameTieBreak = Number(prev.tieBreak || 0) === Number(row.tieBreak || 0)
|
||||||
|
if (!(isSameScore && isSameTieBreak)) {
|
||||||
|
currentRank = index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...row, rank: currentRank }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartListRotation() {
|
||||||
|
stopListRotation()
|
||||||
|
listPageIndex.value = 0
|
||||||
|
|
||||||
|
const autoRotateView = activeView.value === 'prelim_overall' || activeView.value === 'final_overall'
|
||||||
|
if (!autoRotateView) return
|
||||||
|
if (listPageCount.value <= 1) return
|
||||||
|
|
||||||
|
listRotationTimer = setInterval(() => {
|
||||||
|
if (listPageCount.value <= 1) {
|
||||||
|
listPageIndex.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listPageIndex.value = (listPageIndex.value + 1) % listPageCount.value
|
||||||
|
}, rotationIntervalSec.value * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopListRotation() {
|
||||||
|
if (listRotationTimer) {
|
||||||
|
clearInterval(listRotationTimer)
|
||||||
|
listRotationTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartTieRotation() {
|
||||||
|
stopTieRotation()
|
||||||
|
tieGroupIndex.value = 0
|
||||||
|
|
||||||
|
const total = tieGroupCountForActiveView.value
|
||||||
|
if (total <= 1) return
|
||||||
|
if (activeView.value !== 'prelim_tie' && activeView.value !== 'final_tie') return
|
||||||
|
|
||||||
|
tieRotationTimer = setInterval(() => {
|
||||||
|
if (tieGroupCountForActiveView.value <= 1) {
|
||||||
|
tieGroupIndex.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tieGroupIndex.value = (tieGroupIndex.value + 1) % tieGroupCountForActiveView.value
|
||||||
|
}, rotationIntervalSec.value * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTieRotation() {
|
||||||
|
if (tieRotationTimer) {
|
||||||
|
clearInterval(tieRotationTimer)
|
||||||
|
tieRotationTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function boardSlot(board, playerId) {
|
||||||
|
const boardMap = props.positionBoards?.[board] || {}
|
||||||
|
return boardMap[String(playerId)] || boardMap[playerId] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupTieRowsByPositionBoard(board, rows) {
|
||||||
|
const mapped = (rows || []).map((row, index) => {
|
||||||
|
const slot = boardSlot(board, row.playerId)
|
||||||
|
const fallbackGroup = String(Math.floor(index / 6) + 1)
|
||||||
|
const fallbackPos = (index % 6) + 1
|
||||||
|
const groupKeyRaw = String(slot?.groupKey || fallbackGroup).trim()
|
||||||
|
const parsedGroup = Number.parseInt(groupKeyRaw, 10)
|
||||||
|
const groupKey = Number.isFinite(parsedGroup) && parsedGroup > 0 ? String(parsedGroup) : fallbackGroup
|
||||||
|
const position = Number(slot?.position) > 0 ? Math.floor(Number(slot.position)) : fallbackPos
|
||||||
|
return { row, groupKey, position }
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = new Map()
|
||||||
|
for (const entry of mapped) {
|
||||||
|
if (!groups.has(entry.groupKey)) groups.set(entry.groupKey, [])
|
||||||
|
groups.get(entry.groupKey).push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groups.entries()]
|
||||||
|
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||||
|
.map(([groupKey, items]) => ({
|
||||||
|
groupKey,
|
||||||
|
rows: items.sort((a, b) => {
|
||||||
|
if (a.position !== b.position) return a.position - b.position
|
||||||
|
return a.row.playerId - b.row.playerId
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
24
frontend/src/components/ProofModal.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="modal-card proof-modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
<button class="btn btn-outline btn-xs" @click="$emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="proof-modal-image-wrap">
|
||||||
|
<img class="proof-modal-image" :src="image" :alt="title" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
image: { type: String, default: '' },
|
||||||
|
title: { type: String, default: 'Proof' },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['close'])
|
||||||
|
</script>
|
||||||
|
|
||||||
23
frontend/src/components/ResetStageModal.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-overlay" @click.self="$emit('cancel')">
|
||||||
|
<div class="modal-card">
|
||||||
|
<h3 class="modal-title">{{ t('actions.resetScores') }}</h3>
|
||||||
|
<p class="modal-text">{{ t('messages.confirmReset') }}</p>
|
||||||
|
<!-- <p class="modal-text subtle">{{ t('messages.resetProofPrompt') }}</p> -->
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-outline" @click="$emit('confirm', false)">{{ t('actions.resetOnlyScores') }}</button>
|
||||||
|
<button class="btn btn-secondary" @click="$emit('cancel')">{{ t('actions.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['cancel', 'confirm'])
|
||||||
|
</script>
|
||||||
|
|
||||||
61
frontend/src/components/ScoreAdviceModal.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="open" class="modal-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="modal-card ai-advice-modal compact">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3>{{ t('sections.aiAdvisorTitle') }}</h3>
|
||||||
|
<button class="btn btn-outline btn-xs" @click="$emit('close')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-modal-body compact">
|
||||||
|
<div class="ai-image-wrap">
|
||||||
|
<img v-if="image" class="ai-proof-image" :src="image" :alt="t('table.verification')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-info-panel">
|
||||||
|
<template v-if="loading">
|
||||||
|
<div class="loading-state ai-loading-state">
|
||||||
|
<div class="spinner" />
|
||||||
|
<p>{{ t('messages.aiAnalyzing') }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="error">
|
||||||
|
<div class="hint-box danger">{{ error }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="advice">
|
||||||
|
<div class="ai-metric-grid">
|
||||||
|
<div class="ai-metric-card">
|
||||||
|
<p class="ai-label">{{ t('labels.currentScore') }}</p>
|
||||||
|
<p class="ai-value mono">{{ currentScore }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="ai-metric-card highlight">
|
||||||
|
<p class="ai-label">{{ t('labels.aiSuggestedScore') }}</p>
|
||||||
|
<p class="ai-value mono">{{ advice.advisedScore }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="ai-summary">{{ advice.reason }}</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions split">
|
||||||
|
<button class="btn btn-outline" :disabled="loading" @click="$emit('refresh')">{{ t('actions.reAnalyze') }}</button>
|
||||||
|
<button class="btn btn-secondary" @click="$emit('close')">{{ t('actions.cancel') }}</button>
|
||||||
|
<button class="btn btn-ai" :disabled="loading || !advice" @click="$emit('apply')">{{ t('actions.quickApplyAi') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
advice: { type: Object, default: null },
|
||||||
|
currentScore: { type: Number, default: 0 },
|
||||||
|
image: { type: String, default: '' },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
error: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['close', 'refresh', 'apply'])
|
||||||
|
</script>
|
||||||
477
frontend/src/components/ViewModePanel.vue
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<nav class="tab-bar">
|
||||||
|
<button v-for="tab in viewTabs" :key="tab.id" class="tab-btn" :class="{ active: viewTab === tab.id }" @click="$emit('change-tab', tab.id)">
|
||||||
|
{{ tab.label }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section v-show="viewTab === 'groups'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.groupsTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.groupsSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-grid">
|
||||||
|
<article v-for="group in groupSummaries" :key="group.code" class="summary-card" :class="'group-' + group.key">
|
||||||
|
<h3>{{ t('labels.group') }} {{ group.code }}</h3>
|
||||||
|
<p class="summary-value">{{ group.count }}</p>
|
||||||
|
<p class="summary-status ok">{{ t('labels.players') }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="groupedPlayers.length > 0" class="view-group-grid">
|
||||||
|
<article v-for="group in groupedPlayers" :key="'view-group-' + group.code" class="view-group-card" :class="'group-' + group.key">
|
||||||
|
<div class="view-group-card-head">
|
||||||
|
<h3>{{ groupLabel(group.code) }}</h3>
|
||||||
|
<span class="pm-count-badge">{{ group.players.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="player in group.players" :key="'v-g-' + group.code + '-' + player.id" :class="rowClass(player.groupCode)">
|
||||||
|
<td class="mono">{{ player.id }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(player)" :alt="player.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(player) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(player) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div v-else class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="muted center">{{ t('labels.noPlayers') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="viewTab === 'live'" class="panel live-panel">
|
||||||
|
<div class="live-corner">
|
||||||
|
<div class="live-top-controls">
|
||||||
|
<button class="btn btn-outline light" :class="{ active: liveMode === 'rotate' }" @click="$emit('change-live-mode', 'rotate')">
|
||||||
|
{{ t('actions.liveRotate') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline light" :class="{ active: liveMode === 'fixed' }" @click="$emit('change-live-mode', 'fixed')">
|
||||||
|
{{ t('actions.liveFixed') }}
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
v-if="liveMode === 'fixed'"
|
||||||
|
class="group-select"
|
||||||
|
:value="selectedLiveGroup"
|
||||||
|
@change="$emit('change-live-group', $event.target.value)"
|
||||||
|
>
|
||||||
|
<option v-for="group in liveSelectableGroups" :key="'live-group-' + group" :value="group">{{ group }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="live-title">{{ t('sections.liveTitle') }} · {{ liveGroupCode || t('labels.unassigned') }}</div>
|
||||||
|
<p class="live-subtitle">{{ t('sections.liveSubtitle') }}</p>
|
||||||
|
|
||||||
|
<div class="live-grid" v-if="liveMembers.length > 0">
|
||||||
|
<article class="live-card" v-for="member in liveMembers" :key="'live-' + member.id">
|
||||||
|
<img class="live-image" :src="playerImage(member)" :alt="member.nameAr" />
|
||||||
|
<div>
|
||||||
|
<p class="live-number">#{{ member.id }}</p>
|
||||||
|
<p class="live-name-primary">{{ displayName(member) }}</p>
|
||||||
|
<p class="live-name-secondary">{{ secondaryName(member) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state" v-else>{{ t('labels.emptyLive') }}</div>
|
||||||
|
|
||||||
|
<div class="live-progress" :key="'tick-' + liveTick" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="viewTab === 'overall'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.overallTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.overallSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<div>
|
||||||
|
<h3 class="sub-heading">{{ t('labels.allPlayers') }}</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.rank') }}</th>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.group') }}</th>
|
||||||
|
<th>{{ t('table.score') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
<th>{{ t('table.status') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in preliminaryRows" :key="'v-pr-' + row.playerId" :class="{ 'qualified-row': isFinalist(row.playerId) }">
|
||||||
|
<td class="mono rank">{{ row.rank }}</td>
|
||||||
|
<td class="mono">{{ row.playerId }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
|
||||||
|
<td class="mono strong">
|
||||||
|
<span>{{ row.score }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && preliminaryProofStage(row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: preliminaryProofStage(row.playerId), playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor(preliminaryProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="mono">
|
||||||
|
<span>{{ row.tieBreak }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && hasScoreProof('prelim_tiebreak', row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: 'prelim_tiebreak', playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor('prelim_tiebreak', row.playerId)" :alt="t('table.verification')" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="isFinalist(row.playerId)" class="badge success">{{ t('labels.finalist') }}</span>
|
||||||
|
<span v-else class="muted">{{ t('labels.notFinalist') }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="preliminaryRows.length === 0"><td colspan="7" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="sub-heading">{{ t('labels.top12') }}</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.seed') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.group') }}</th>
|
||||||
|
<th>{{ t('table.score') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in finalists" :key="'v-top-' + row.playerId" class="qualified-row">
|
||||||
|
<td class="mono rank">{{ row.seed }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
|
||||||
|
<td class="mono strong">{{ row.score }}</td>
|
||||||
|
<td class="mono">{{ row.tieBreak }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="finalists.length === 0"><td colspan="5" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint-box danger" v-if="prelimTie.required && !prelimTie.resolved">
|
||||||
|
{{ t('messages.prelimTieUnresolved') }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="viewTab === 'final'" class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.finalTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.finalSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="two-column">
|
||||||
|
<div>
|
||||||
|
<h3 class="sub-heading">{{ t('labels.finalGroup1') }}</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.seed') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.score') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in finalGroup1" :key="'v-f1-' + row.playerId">
|
||||||
|
<td class="mono">{{ row.seed }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">
|
||||||
|
<span>{{ finalTotalScore(row.playerId) }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && finalProofStage(row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: finalProofStage(row.playerId), playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor(finalProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="finalGroup1.length === 0"><td colspan="3" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="sub-heading">{{ t('labels.finalGroup2') }}</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.seed') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.score') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in finalGroup2" :key="'v-f2-' + row.playerId">
|
||||||
|
<td class="mono">{{ row.seed }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">
|
||||||
|
<span>{{ row.score }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && finalProofStage(row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: finalProofStage(row.playerId), playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor(finalProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="finalGroup2.length === 0"><td colspan="3" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="sub-heading mt-32">{{ t('sections.finalRanking') }}</h3>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t('table.rank') }}</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th>{{ t('table.score') }}</th>
|
||||||
|
<th>{{ t('table.tieScore') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in finalRows" :key="'v-fr-' + row.playerId" :class="{ 'podium-row': row.rank <= 3 }">
|
||||||
|
<td class="mono rank">{{ row.rank }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">
|
||||||
|
<span>{{ row.score }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && finalProofStage(row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: finalProofStage(row.playerId), playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor(finalProofStage(row.playerId), row.playerId)" :alt="t('table.verification')" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="mono">
|
||||||
|
<span>{{ row.tieBreak }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canViewProofs && hasScoreProof('final_tiebreak', row.playerId)"
|
||||||
|
class="proof-mini"
|
||||||
|
@click="$emit('open-proof', { stage: 'final_tiebreak', playerId: row.playerId })"
|
||||||
|
>
|
||||||
|
<img :src="scoreProofFor('final_tiebreak', row.playerId)" :alt="t('table.verification')" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="finalRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint-box danger" v-if="finalTie.required && !finalTie.resolved">
|
||||||
|
{{ t('messages.finalTieUnresolved') }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-show="viewTab === 'podium'" class="panel podium-panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.podiumTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.podiumSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="podium-wrapper">
|
||||||
|
<article class="podium-col pos-2">
|
||||||
|
<div class="podium-avatar-wrap">
|
||||||
|
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
|
||||||
|
</div>
|
||||||
|
<div class="podium-medal-icon">🥈</div>
|
||||||
|
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
|
||||||
|
<div class="podium-score-wrap">
|
||||||
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
|
||||||
|
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="podium-col pos-1">
|
||||||
|
<div class="podium-avatar-wrap">
|
||||||
|
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
|
||||||
|
</div>
|
||||||
|
<div class="podium-medal-icon">🥇</div>
|
||||||
|
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
|
||||||
|
<div class="podium-score-wrap">
|
||||||
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
|
||||||
|
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="podium-col pos-3">
|
||||||
|
<div class="podium-avatar-wrap">
|
||||||
|
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
|
||||||
|
</div>
|
||||||
|
<div class="podium-medal-icon">🥉</div>
|
||||||
|
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
|
||||||
|
<div class="podium-score-wrap">
|
||||||
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
|
||||||
|
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
viewTabs: { type: Array, required: true },
|
||||||
|
viewTab: { type: String, required: true },
|
||||||
|
groupSummaries: { type: Array, required: true },
|
||||||
|
playersSorted: { type: Array, required: true },
|
||||||
|
rowClass: { type: Function, required: true },
|
||||||
|
playerImage: { type: Function, required: true },
|
||||||
|
displayName: { type: Function, required: true },
|
||||||
|
secondaryName: { type: Function, required: true },
|
||||||
|
liveMode: { type: String, required: true },
|
||||||
|
selectedLiveGroup: { type: String, default: '' },
|
||||||
|
liveSelectableGroups: { type: Array, required: true },
|
||||||
|
liveGroupCode: { type: String, default: '' },
|
||||||
|
liveMembers: { type: Array, required: true },
|
||||||
|
liveTick: { type: Number, required: true },
|
||||||
|
preliminaryRows: { type: Array, required: true },
|
||||||
|
finalists: { type: Array, required: true },
|
||||||
|
isFinalist: { type: Function, required: true },
|
||||||
|
prelimTie: { type: Object, required: true },
|
||||||
|
finalGroup1: { type: Array, required: true },
|
||||||
|
finalGroup2: { type: Array, required: true },
|
||||||
|
finalRows: { type: Array, required: true },
|
||||||
|
finalTie: { type: Object, required: true },
|
||||||
|
finalTotalScore: { type: Function, required: true },
|
||||||
|
preliminaryProofStage: { type: Function, required: true },
|
||||||
|
finalProofStage: { type: Function, required: true },
|
||||||
|
podiumOrdered: { type: Array, required: true },
|
||||||
|
podiumImage: { type: Function, required: true },
|
||||||
|
podiumName: { type: Function, required: true },
|
||||||
|
podiumHasResult: { type: Function, required: true },
|
||||||
|
podiumScoreMain: { type: Function, required: true },
|
||||||
|
podiumTieSubtitle: { type: Function, required: true },
|
||||||
|
canViewProofs: { type: Boolean, default: false },
|
||||||
|
hasScoreProof: { type: Function, required: true },
|
||||||
|
scoreProofFor: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedPlayers = computed(() => {
|
||||||
|
const map = new Map()
|
||||||
|
const unassigned = props.t('labels.unassigned')
|
||||||
|
for (const player of props.playersSorted) {
|
||||||
|
const code = (player.groupCode || '').trim() || unassigned
|
||||||
|
if (!map.has(code)) map.set(code, [])
|
||||||
|
map.get(code).push(player)
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a[0] === unassigned) return 1
|
||||||
|
if (b[0] === unassigned) return -1
|
||||||
|
return String(a[0]).localeCompare(String(b[0]), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
})
|
||||||
|
.map(([code, players]) => ({
|
||||||
|
code,
|
||||||
|
key: resolveGroupKey(code, unassigned),
|
||||||
|
players: [...players].sort((p1, p2) => p1.id - p2.id),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function resolveGroupKey(code, unassigned) {
|
||||||
|
if (code === unassigned) return 'u'
|
||||||
|
const normalized = String(code || '').trim().toUpperCase()
|
||||||
|
if (normalized.startsWith('A')) return 'a'
|
||||||
|
if (normalized.startsWith('B')) return 'b'
|
||||||
|
if (normalized.startsWith('C')) return 'c'
|
||||||
|
if (normalized.startsWith('D')) return 'd'
|
||||||
|
return 'u'
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLabel(code) {
|
||||||
|
const unassigned = props.t('labels.unassigned')
|
||||||
|
if (code === unassigned) return unassigned
|
||||||
|
return `${props.t('labels.group')} ${code}`
|
||||||
|
}
|
||||||
|
|
||||||
|
defineEmits(['change-tab', 'change-live-mode', 'change-live-group', 'open-proof'])
|
||||||
|
</script>
|
||||||
203
frontend/src/components/admin/LiveSettingsCard.vue
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<section class="live-settings-card">
|
||||||
|
<div class="live-settings-head">
|
||||||
|
<h3>{{ t('labels.liveModeVisibility') }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-settings-row">
|
||||||
|
<label class="control-label">{{ t('labels.activeLiveView') }}</label>
|
||||||
|
<select class="name-input" :value="liveSettings.activeView" @change="emitPatch({ activeView: $event.target.value })">
|
||||||
|
<option value="group_live">{{ t('labels.liveViewGroup') }}</option>
|
||||||
|
<option value="prelim_tie">{{ t('labels.liveViewPrelimTie') }}</option>
|
||||||
|
<option value="prelim_overall">{{ t('labels.liveViewPrelimOverall') }}</option>
|
||||||
|
<option value="final_groups">{{ t('labels.liveViewFinalGroups') }}</option>
|
||||||
|
<option value="final_tie">{{ t('labels.liveViewFinalTie') }}</option>
|
||||||
|
<option value="final_overall">{{ t('labels.liveViewFinalOverall') }}</option>
|
||||||
|
<option value="podium">{{ t('labels.liveViewPodium') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-settings-row">
|
||||||
|
<label class="control-label">{{ t('labels.groupDisplayMode') }}</label>
|
||||||
|
<div class="inline-settings">
|
||||||
|
<select class="name-input" :value="liveSettings.groupDisplayMode" @change="emitPatch({ groupDisplayMode: $event.target.value })">
|
||||||
|
<option value="rotate">{{ t('actions.liveModeRotate') }}</option>
|
||||||
|
<option value="fixed">{{ t('actions.liveModeFixed') }}</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-if="liveSettings.groupDisplayMode === 'fixed'"
|
||||||
|
class="name-input"
|
||||||
|
:value="liveSettings.groupFixedCode || ''"
|
||||||
|
@change="emitPatch({ groupFixedCode: $event.target.value })"
|
||||||
|
>
|
||||||
|
<option value="">{{ t('labels.unassigned') }}</option>
|
||||||
|
<option v-for="group in assignableGroups" :key="'live-fixed-group-' + group" :value="group">{{ group }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-settings-row">
|
||||||
|
<label class="control-label">{{ t('labels.finalDisplayMode') }}</label>
|
||||||
|
<div class="inline-settings">
|
||||||
|
<select class="name-input" :value="liveSettings.finalDisplayMode" @change="emitPatch({ finalDisplayMode: $event.target.value })">
|
||||||
|
<option value="rotate">{{ t('actions.liveModeRotate') }}</option>
|
||||||
|
<option value="fixed">{{ t('actions.liveModeFixed') }}</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-if="liveSettings.finalDisplayMode === 'fixed'"
|
||||||
|
class="name-input"
|
||||||
|
:value="String(liveSettings.finalFixedGroup || 1)"
|
||||||
|
@change="emitPatch({ finalFixedGroup: Number($event.target.value) || 1 })"
|
||||||
|
>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-settings-row">
|
||||||
|
<label class="control-label">{{ t('labels.rotationIntervalSec') }}</label>
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
type="number"
|
||||||
|
min="3"
|
||||||
|
max="30"
|
||||||
|
:value="liveSettings.rotationIntervalSec || 5"
|
||||||
|
@change="emitPatch({ rotationIntervalSec: Number($event.target.value) || 5 })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-settings-row">
|
||||||
|
<label class="control-label">{{ t('labels.rotationPlayerCount') }}</label>
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
type="number"
|
||||||
|
min="3"
|
||||||
|
max="40"
|
||||||
|
:value="liveSettings.rotationPlayerCount || 12"
|
||||||
|
@change="emitPatch({ rotationPlayerCount: Number($event.target.value) || 12 })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="live-settings-divider" />
|
||||||
|
|
||||||
|
<div class="live-settings-row">
|
||||||
|
<label class="control-label">{{ t('labels.stageControl') }}</label>
|
||||||
|
<div class="stage-control-grid">
|
||||||
|
<div class="stage-select-card">
|
||||||
|
<p class="stage-select-label">{{ t('labels.stagePhase') }}</p>
|
||||||
|
<select class="name-input" :value="stageControl.phase" @change="updateStageControl({ phase: $event.target.value })">
|
||||||
|
<option value="preliminary">{{ t('labels.phasePreliminary') }}</option>
|
||||||
|
<option value="prelim_tiebreak">{{ t('labels.phasePrelimTie') }}</option>
|
||||||
|
<option value="final">{{ t('labels.phaseFinal') }}</option>
|
||||||
|
<option value="final_tiebreak">{{ t('labels.phaseFinalTie') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="stage-select-card">
|
||||||
|
<p class="stage-select-label">{{ t('labels.stageGroup') }}</p>
|
||||||
|
<select class="name-input" :value="stageControl.group" @change="updateStageControl({ group: $event.target.value })" :disabled="groupOptions.length === 0">
|
||||||
|
<option v-if="groupOptions.length === 0" value="">{{ t('messages.noGroupForPhase') }}</option>
|
||||||
|
<option v-for="group in groupOptions" :key="'stage-group-' + stageControl.phase + '-' + group" :value="group">{{ group }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="stage-select-card">
|
||||||
|
<p class="stage-select-label">{{ t('labels.stageRound') }}</p>
|
||||||
|
<select class="name-input" :value="String(stageControl.round)" @change="updateStageControl({ round: Number($event.target.value) || 1 })">
|
||||||
|
<option v-for="round in roundOptions" :key="'stage-round-' + stageControl.phase + '-' + round" :value="String(round)">
|
||||||
|
{{ round }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="stage-selection-preview">{{ selectedStageSummary }}</p>
|
||||||
|
<div class="panel-actions compact stage-action-row">
|
||||||
|
<button class="btn btn-primary stage-start-btn" @click="$emit('start-stage')" :disabled="groupOptions.length === 0">
|
||||||
|
{{ t('actions.startStage') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline" @click="$emit('end-stage')">{{ t('actions.endStage') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stage-status-grid">
|
||||||
|
<div class="stage-status-card">
|
||||||
|
<p class="stage-status-title">{{ t('labels.currentStage') }}</p>
|
||||||
|
<template v-if="currentStage">
|
||||||
|
<p class="stage-status-main">{{ formatStageMain(currentStage) }}</p>
|
||||||
|
<p class="stage-status-sub">{{ t('labels.startedAt') }}: {{ currentStage.startedAt || '-' }}</p>
|
||||||
|
<div class="panel-actions compact stage-status-actions">
|
||||||
|
<button class="btn btn-primary btn-xs" @click="$emit('navigate-current-stage')">{{ t('actions.goToStageScoring') }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-else class="stage-status-empty">{{ t('labels.noCurrentStage') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="stage-status-card">
|
||||||
|
<p class="stage-status-title">{{ t('labels.lastStage') }}</p>
|
||||||
|
<template v-if="lastStage">
|
||||||
|
<p class="stage-status-main">{{ formatStageMain(lastStage) }}</p>
|
||||||
|
<p class="stage-status-sub">{{ t('labels.startedAt') }}: {{ lastStage.startedAt || '-' }}</p>
|
||||||
|
<p v-if="lastStage.endedAt" class="stage-status-sub">{{ t('labels.endedAt') }}: {{ lastStage.endedAt }}</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="stage-status-empty">{{ t('labels.noLastStage') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
liveSettings: { type: Object, required: true },
|
||||||
|
assignableGroups: { type: Array, required: true },
|
||||||
|
stageControl: { type: Object, required: true },
|
||||||
|
stageOptions: { type: Object, required: true },
|
||||||
|
currentStage: { type: Object, default: null },
|
||||||
|
lastStage: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update-live-setting', 'update-stage-control', 'start-stage', 'end-stage', 'navigate-current-stage'])
|
||||||
|
|
||||||
|
const groupOptions = computed(() => {
|
||||||
|
const key = String(props.stageControl.phase || '').trim().toLowerCase()
|
||||||
|
const list = props.stageOptions?.[key]
|
||||||
|
return Array.isArray(list) ? list : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const roundOptions = computed(() => {
|
||||||
|
const phase = String(props.stageControl.phase || '').trim().toLowerCase()
|
||||||
|
if (phase === 'preliminary') return [1, 2, 3]
|
||||||
|
if (phase === 'final') return [1, 2]
|
||||||
|
return [1]
|
||||||
|
})
|
||||||
|
const selectedStageSummary = computed(() => {
|
||||||
|
const phase = formatPhase(props.stageControl.phase)
|
||||||
|
const group = String(props.stageControl.group || '').trim() || '-'
|
||||||
|
const round = Number(props.stageControl.round || 1)
|
||||||
|
return `${props.t('labels.stagePhase')}: ${phase} · ${props.t('labels.stageGroup')}: ${group} · ${props.t('labels.stageRound')}: ${round}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function emitPatch(patch) {
|
||||||
|
emit('update-live-setting', patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStageControl(patch) {
|
||||||
|
emit('update-stage-control', patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPhase(phase) {
|
||||||
|
const normalized = String(phase || '').trim().toLowerCase()
|
||||||
|
if (normalized === 'preliminary') return props.t('labels.phasePreliminary')
|
||||||
|
if (normalized === 'prelim_tiebreak') return props.t('labels.phasePrelimTie')
|
||||||
|
if (normalized === 'final') return props.t('labels.phaseFinal')
|
||||||
|
if (normalized === 'final_tiebreak') return props.t('labels.phaseFinalTie')
|
||||||
|
return normalized || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStageMain(stage) {
|
||||||
|
if (!stage) return '-'
|
||||||
|
const group = String(stage.group || '').trim() || '-'
|
||||||
|
const round = Number(stage.round || 1)
|
||||||
|
return `${formatPhase(stage.phase)} · ${props.t('labels.stageGroup')} ${group} · ${props.t('labels.stageRound')} ${round}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
369
frontend/src/components/admin/MultiRoundScoreEditor.vue
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="stage-filter-bar" v-if="showFilter">
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
:value="filterText"
|
||||||
|
:placeholder="t('actions.searchPlayer')"
|
||||||
|
@input="$emit('update:filter', $event.target.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="section in groupedSections"
|
||||||
|
:key="'multi-section-' + section.key"
|
||||||
|
class="score-section-block"
|
||||||
|
:class="{ 'score-section-overflow': isOverflow(section) }"
|
||||||
|
:style="section.style"
|
||||||
|
>
|
||||||
|
<button class="score-section-toggle" type="button" @click="toggleSection(section.key)">
|
||||||
|
<span class="score-section-title">{{ section.label }}</span>
|
||||||
|
<span class="score-section-meta">{{ section.rows.length }} • {{ isCollapsed(section.key) ? '+' : '−' }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="isOverflow(section)" class="score-section-alert">{{ section.label }} > {{ overflowLimit }}</div>
|
||||||
|
|
||||||
|
<template v-if="!isCollapsed(section.key)">
|
||||||
|
<div class="table-wrap desktop-score-table">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th v-if="showGroup">{{ t('table.group') }}</th>
|
||||||
|
<th v-if="showRank">{{ t('table.rank') }}</th>
|
||||||
|
<th v-if="showSeed">{{ t('table.seed') }}</th>
|
||||||
|
<th
|
||||||
|
v-for="round in roundStages"
|
||||||
|
:key="'head-' + round.stage"
|
||||||
|
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
|
||||||
|
>
|
||||||
|
{{ round.label }}
|
||||||
|
</th>
|
||||||
|
<th>{{ t('table.total') }}</th>
|
||||||
|
<th v-if="showProofControls">{{ t('table.verification') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, index) in section.rows" :key="'multi-row-' + section.key + '-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row, section)">
|
||||||
|
<td class="mono">{{ index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td v-if="showGroup" class="mono">{{ groupCell(row) }}</td>
|
||||||
|
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
|
||||||
|
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
|
||||||
|
<td
|
||||||
|
v-for="round in roundStages"
|
||||||
|
:key="'cell-' + round.stage + '-' + row.playerId"
|
||||||
|
class="multi-round-cell"
|
||||||
|
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="score-input score-input-compact"
|
||||||
|
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(round.stage, section) }"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
:value="scoreInputValue(round.stage, row.playerId)"
|
||||||
|
@focus="onScoreFocus(round.stage, row.playerId)"
|
||||||
|
@input="onScoreInput(round.stage, row.playerId, $event)"
|
||||||
|
@blur="onScoreCommit(round.stage, row.playerId)"
|
||||||
|
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="mono strong">{{ totalScore(row.playerId) }}</td>
|
||||||
|
<td v-if="showProofControls" class="verify-round-cell">
|
||||||
|
<div class="verify-round-list">
|
||||||
|
<div v-for="round in roundStages" :key="'verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
|
||||||
|
<span class="verify-round-label">{{ round.label }}</span>
|
||||||
|
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
|
||||||
|
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
v-if="hasScoreProof(round.stage, row.playerId)"
|
||||||
|
class="proof-thumb"
|
||||||
|
:src="scoreProofFor(round.stage, row.playerId)"
|
||||||
|
:alt="t('table.verification')"
|
||||||
|
@click="openProofPreview(round.stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-score-cards">
|
||||||
|
<article v-for="(row, index) in section.rows" :key="'multi-mobile-' + section.key + '-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row, section)">
|
||||||
|
<div class="score-card-head">
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mono">#{{ index + 1 }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="score-card-meta">
|
||||||
|
<span v-if="showGroup">{{ t('table.group') }}: {{ groupCell(row) }}</span>
|
||||||
|
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
|
||||||
|
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-round-grid">
|
||||||
|
<label
|
||||||
|
v-for="round in roundStages"
|
||||||
|
:key="'mobile-round-' + round.stage + '-' + row.playerId"
|
||||||
|
class="mobile-round-item"
|
||||||
|
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
|
||||||
|
>
|
||||||
|
<span class="score-label">{{ round.label }}</span>
|
||||||
|
<input
|
||||||
|
class="score-input"
|
||||||
|
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(round.stage, section) }"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
:value="scoreInputValue(round.stage, row.playerId)"
|
||||||
|
@focus="onScoreFocus(round.stage, row.playerId)"
|
||||||
|
@input="onScoreInput(round.stage, row.playerId, $event)"
|
||||||
|
@blur="onScoreCommit(round.stage, row.playerId)"
|
||||||
|
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-total-line">
|
||||||
|
<span class="muted">{{ t('table.total') }}</span>
|
||||||
|
<strong class="mono">{{ totalScore(row.playerId) }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showProofControls" class="mobile-verify-block">
|
||||||
|
<p class="mobile-verify-title">{{ t('table.verification') }}</p>
|
||||||
|
<div class="verify-round-list mobile">
|
||||||
|
<div v-for="round in roundStages" :key="'mobile-verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
|
||||||
|
<span class="verify-round-label">{{ round.label }}</span>
|
||||||
|
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
|
||||||
|
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
v-if="hasScoreProof(round.stage, row.playerId)"
|
||||||
|
class="proof-thumb"
|
||||||
|
:src="scoreProofFor(round.stage, row.playerId)"
|
||||||
|
:alt="t('table.verification')"
|
||||||
|
@click="openProofPreview(round.stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { groupThemeStyleForKey, scoreGroupStyle } from '../../utils/scoreGroupTheme'
|
||||||
|
import { normalizedGroupCode } from '../../utils/groups'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
rows: { type: Array, required: true },
|
||||||
|
roundStages: { type: Array, required: true },
|
||||||
|
totalScore: { type: Function, required: true },
|
||||||
|
showFilter: { type: Boolean, default: true },
|
||||||
|
filterText: { type: String, default: '' },
|
||||||
|
showGroup: { type: Boolean, default: false },
|
||||||
|
showRank: { type: Boolean, default: false },
|
||||||
|
showSeed: { type: Boolean, default: false },
|
||||||
|
sectionByGroup: { type: Boolean, default: false },
|
||||||
|
overflowLimit: { type: Number, default: 0 },
|
||||||
|
showProofControls: { type: Boolean, default: true },
|
||||||
|
playerImage: { type: Function, required: true },
|
||||||
|
displayName: { type: Function, required: true },
|
||||||
|
secondaryName: { type: Function, required: true },
|
||||||
|
scoreInputValue: { type: Function, required: true },
|
||||||
|
scoreInputInvalid: { type: Function, required: true },
|
||||||
|
onScoreFocus: { type: Function, required: true },
|
||||||
|
onScoreInput: { type: Function, required: true },
|
||||||
|
onScoreCommit: { type: Function, required: true },
|
||||||
|
hasScoreProof: { type: Function, required: true },
|
||||||
|
scoreProofFor: { type: Function, required: true },
|
||||||
|
openScoreProofUploader: { type: Function, required: true },
|
||||||
|
openProofPreview: { type: Function, required: true },
|
||||||
|
highlightStage: { type: String, default: '' },
|
||||||
|
highlightGroup: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:filter'])
|
||||||
|
|
||||||
|
const collapsedSections = ref({})
|
||||||
|
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const query = String(props.filterText || '').trim().toLowerCase()
|
||||||
|
if (!query) return props.rows
|
||||||
|
return props.rows.filter((row) => {
|
||||||
|
const ar = String(row.nameAr || '')
|
||||||
|
const en = String(row.nameEn || '').toLowerCase()
|
||||||
|
return ar.includes(query) || en.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedSections = computed(() => {
|
||||||
|
if (!props.sectionByGroup) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
label: props.t('labels.allPlayers'),
|
||||||
|
rows: filteredRows.value,
|
||||||
|
style: null,
|
||||||
|
},
|
||||||
|
].filter((section) => section.rows.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = new Map()
|
||||||
|
for (const row of filteredRows.value) {
|
||||||
|
const key = sectionKeyForRow(row)
|
||||||
|
if (!buckets.has(key)) {
|
||||||
|
buckets.set(key, {
|
||||||
|
key,
|
||||||
|
label: sectionLabelForKey(key),
|
||||||
|
rows: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
buckets.get(key).rows.push(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...buckets.values()]
|
||||||
|
.sort((a, b) => sectionSortValue(a.key) - sectionSortValue(b.key) || String(a.key).localeCompare(String(b.key)))
|
||||||
|
.map((section) => ({
|
||||||
|
...section,
|
||||||
|
style: sectionStyleFor(section.key, section.rows[0]),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
groupedSections,
|
||||||
|
(sections) => {
|
||||||
|
const next = {}
|
||||||
|
for (const section of sections) {
|
||||||
|
next[section.key] = collapsedSections.value[section.key] || false
|
||||||
|
}
|
||||||
|
collapsedSections.value = next
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function sectionKeyForRow(row) {
|
||||||
|
const boardGroup = String(row?._boardGroupKey || '').trim()
|
||||||
|
if (boardGroup) return `B${boardGroup}`
|
||||||
|
|
||||||
|
const finalGroup = Number.parseInt(String(row?.finalGroup || ''), 10)
|
||||||
|
if (Number.isFinite(finalGroup) && finalGroup > 0) return `F${finalGroup}`
|
||||||
|
|
||||||
|
const group = normalizedGroupCode(row?.groupCode || '')
|
||||||
|
if (group) return `G${group}`
|
||||||
|
|
||||||
|
return 'U'
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionLabelForKey(key) {
|
||||||
|
if (key === 'U') return `${props.t('labels.group')} ${props.t('labels.unassigned')}`
|
||||||
|
if (key.startsWith('B')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||||
|
if (key.startsWith('F')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||||
|
if (key.startsWith('G')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||||
|
return `${props.t('labels.group')} ${key}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionSortValue(key) {
|
||||||
|
if (key === 'U') return 9999
|
||||||
|
if (key.startsWith('B')) {
|
||||||
|
const raw = key.slice(1)
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) return parsed
|
||||||
|
const code = String(raw).toUpperCase()
|
||||||
|
if (code === 'A') return 1
|
||||||
|
if (code === 'B') return 2
|
||||||
|
if (code === 'C') return 3
|
||||||
|
if (code === 'D') return 4
|
||||||
|
return 5000
|
||||||
|
}
|
||||||
|
if (key.startsWith('F')) return Number.parseInt(key.slice(1), 10) || 9998
|
||||||
|
if (key.startsWith('G')) {
|
||||||
|
const code = String(key.slice(1)).toUpperCase()
|
||||||
|
if (code === 'A') return 1
|
||||||
|
if (code === 'B') return 2
|
||||||
|
if (code === 'C') return 3
|
||||||
|
if (code === 'D') return 4
|
||||||
|
}
|
||||||
|
return 5000
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupCell(row) {
|
||||||
|
if (row?._boardGroupKey) return row._boardGroupKey
|
||||||
|
if (row.finalGroup === 1 || row.finalGroup === 2) {
|
||||||
|
return row.finalGroup
|
||||||
|
}
|
||||||
|
return row.groupCode || props.t('labels.unassigned')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionStyleFor(key, sampleRow) {
|
||||||
|
if (key.startsWith('B')) return groupThemeStyleForKey(key)
|
||||||
|
return scoreGroupStyle(sampleRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreGroupStyleFor(row, section) {
|
||||||
|
if (section?.key?.startsWith('B') && section?.style) return section.style
|
||||||
|
return scoreGroupStyle(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCollapsed(sectionKey) {
|
||||||
|
return Boolean(collapsedSections.value[sectionKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSection(sectionKey) {
|
||||||
|
collapsedSections.value[sectionKey] = !collapsedSections.value[sectionKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverflow(section) {
|
||||||
|
const limit = Number(props.overflowLimit || 0)
|
||||||
|
if (!Number.isFinite(limit) || limit <= 0) return false
|
||||||
|
return Number(section?.rows?.length || 0) > limit
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHighlightedStage(stage, section) {
|
||||||
|
const stageMatch = String(props.highlightStage || '').trim().toLowerCase() === String(stage || '').trim().toLowerCase()
|
||||||
|
if (!stageMatch) return false
|
||||||
|
const wanted = String(props.highlightGroup || '').trim().toUpperCase()
|
||||||
|
if (!wanted) return true
|
||||||
|
return sectionGroupKey(section) === wanted
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionGroupKey(section) {
|
||||||
|
const key = String(section?.key || '').trim()
|
||||||
|
if (!key) return ''
|
||||||
|
if (key === 'U') return 'UNASSIGNED'
|
||||||
|
if (key.startsWith('B') || key.startsWith('F') || key.startsWith('G') || key.startsWith('T')) {
|
||||||
|
return String(key.slice(1)).trim().toUpperCase()
|
||||||
|
}
|
||||||
|
return key.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
286
frontend/src/components/admin/PlayersManagementTab.vue
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h2>{{ t('sections.playersTitle') }}</h2>
|
||||||
|
<p>{{ t('sections.playersSubtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-config-card">
|
||||||
|
<div class="pm-config-top">
|
||||||
|
<div class="pm-config-input">
|
||||||
|
<input
|
||||||
|
:value="groupSetupInput"
|
||||||
|
class="name-input en"
|
||||||
|
dir="ltr"
|
||||||
|
:placeholder="t('sections.groupsConfigPlaceholder')"
|
||||||
|
@input="$emit('update:group-setup-input', $event.target.value)"
|
||||||
|
/>
|
||||||
|
<button class="btn btn-outline" @click="$emit('save-group-setup')">{{ t('actions.updateGroups') }}</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" @click="$emit('auto-group-even')">{{ t('actions.randomEvenGroups') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="summary-grid admin-summary-grid">
|
||||||
|
<article v-for="card in adminGroupCards" :key="'admin-group-' + card.key" class="summary-card" :class="'group-' + card.key">
|
||||||
|
<h3>{{ card.label }}</h3>
|
||||||
|
<p class="summary-value">{{ card.count }}</p>
|
||||||
|
<p class="summary-status ok">{{ t('labels.players') }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-composer-card">
|
||||||
|
<div class="pm-composer-grid">
|
||||||
|
<input
|
||||||
|
:value="newPlayer.nameAr"
|
||||||
|
class="name-input ar"
|
||||||
|
:placeholder="t('table.arabicName')"
|
||||||
|
@input="$emit('update:new-player', { key: 'nameAr', value: $event.target.value })"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
:value="newPlayer.nameEn"
|
||||||
|
class="name-input en"
|
||||||
|
dir="ltr"
|
||||||
|
:placeholder="t('table.englishName')"
|
||||||
|
@input="$emit('update:new-player', { key: 'nameEn', value: $event.target.value })"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
:value="newPlayer.groupCode"
|
||||||
|
class="name-input"
|
||||||
|
@change="$emit('update:new-player', { key: 'groupCode', value: $event.target.value })"
|
||||||
|
>
|
||||||
|
<option value="">{{ groupOptionLabel('') }}</option>
|
||||||
|
<option v-for="group in assignableGroups" :key="'new-group-' + group" :value="group">{{ groupOptionLabel(group) }}</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" @click="$emit('create-player')">{{ t('actions.addPlayer') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pm-composer-actions">
|
||||||
|
<button class="btn btn-outline" @click="$emit('convert-new-name', 'ar_to_en')">{{ t('actions.convertArToEn') }}</button>
|
||||||
|
<button class="btn btn-outline" @click="$emit('convert-new-name', 'en_to_ar')">{{ t('actions.convertEnToAr') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="players-tools players-tools-elevated">
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
:value="playerFilter"
|
||||||
|
:placeholder="t('actions.searchPlayer')"
|
||||||
|
@input="$emit('update:player-filter', $event.target.value)"
|
||||||
|
/>
|
||||||
|
<div class="players-sort-box">
|
||||||
|
<label class="control-label">{{ t('labels.sortBy') }}</label>
|
||||||
|
<select class="name-input" :value="playerSort" @change="$emit('update:player-sort', $event.target.value)">
|
||||||
|
<option value="id">{{ t('actions.sortById') }}</option>
|
||||||
|
<option value="nameAr">{{ t('actions.sortByArabic') }}</option>
|
||||||
|
<option value="nameEn">{{ t('actions.sortByEnglish') }}</option>
|
||||||
|
<option value="group">{{ t('actions.sortByGroup') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group-cards-grid">
|
||||||
|
<article v-for="group in groupedCards" :key="'group-card-' + group.code" class="group-player-card" :class="'group-' + group.key">
|
||||||
|
<header class="group-player-card-head">
|
||||||
|
<h3>{{ group.label }}</h3>
|
||||||
|
<div class="group-card-head-actions">
|
||||||
|
<span class="pm-count-badge mono">{{ group.players.length }}</span>
|
||||||
|
<button class="group-card-toggle" type="button" @click="toggleGroup(group.code)">
|
||||||
|
{{ isGroupCollapsed(group.code) ? '+' : '−' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="group-player-list" v-if="group.players.length > 0 && !isGroupCollapsed(group.code)">
|
||||||
|
<div class="group-player-row" v-for="player in group.players" :key="'card-player-' + player.id">
|
||||||
|
<div class="group-player-top">
|
||||||
|
<div class="competitor-cell">
|
||||||
|
<img :src="playerImage(player)" :alt="player.nameAr" class="competitor-image clickable" @click="$emit('open-image-uploader', player.id)" />
|
||||||
|
<div class="name-edit-grid vertical">
|
||||||
|
<input
|
||||||
|
class="name-input ar"
|
||||||
|
:value="player.nameAr"
|
||||||
|
:placeholder="t('table.arabicName')"
|
||||||
|
@blur="$emit('update-player-field', { player, field: 'nameAr', event: $event })"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="name-input en"
|
||||||
|
dir="ltr"
|
||||||
|
:value="player.nameEn"
|
||||||
|
:placeholder="t('table.englishName')"
|
||||||
|
@blur="$emit('update-player-field', { player, field: 'nameEn', event: $event })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-actions compact name-convert-row">
|
||||||
|
<button class="btn btn-outline btn-xs" @click="$emit('convert-row-name', { player, direction: 'ar_to_en' })">{{ t('actions.convertArToEn') }}</button>
|
||||||
|
<button class="btn btn-outline btn-xs" @click="$emit('convert-row-name', { player, direction: 'en_to_ar' })">{{ t('actions.convertEnToAr') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group-player-actions">
|
||||||
|
<select
|
||||||
|
class="name-input"
|
||||||
|
:value="normalizedGroupCode(player.groupCode)"
|
||||||
|
@change="$emit('update-player-group', { player, event: $event })"
|
||||||
|
>
|
||||||
|
<option value="">{{ groupOptionLabel('') }}</option>
|
||||||
|
<option v-for="item in assignableGroups" :key="'player-group-' + player.id + '-' + item" :value="item">
|
||||||
|
{{ groupOptionLabel(item) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-outline" @click="$emit('remove-player-image', player.id)">{{ t('actions.removeImage') }}</button>
|
||||||
|
<button class="btn btn-danger" @click="$emit('delete-player', player.id)">{{ t('actions.delete') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="group.players.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
groupSetupInput: { type: String, default: '' },
|
||||||
|
adminGroupCards: { type: Array, required: true },
|
||||||
|
newPlayer: { type: Object, required: true },
|
||||||
|
assignableGroups: { type: Array, required: true },
|
||||||
|
playersSorted: { type: Array, required: true },
|
||||||
|
playerFilter: { type: String, default: '' },
|
||||||
|
playerSort: { type: String, default: 'id' },
|
||||||
|
playerImage: { type: Function, required: true },
|
||||||
|
groupOptionLabel: { type: Function, required: true },
|
||||||
|
normalizedGroupCode: { type: Function, required: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits([
|
||||||
|
'update:group-setup-input',
|
||||||
|
'save-group-setup',
|
||||||
|
'auto-group-even',
|
||||||
|
'update:new-player',
|
||||||
|
'create-player',
|
||||||
|
'convert-new-name',
|
||||||
|
'update:player-filter',
|
||||||
|
'update:player-sort',
|
||||||
|
'open-image-uploader',
|
||||||
|
'update-player-field',
|
||||||
|
'update-player-group',
|
||||||
|
'convert-row-name',
|
||||||
|
'remove-player-image',
|
||||||
|
'delete-player',
|
||||||
|
])
|
||||||
|
|
||||||
|
const filteredPlayers = computed(() => {
|
||||||
|
const query = String(props.playerFilter || '').trim().toLowerCase()
|
||||||
|
if (!query) return props.playersSorted
|
||||||
|
return props.playersSorted.filter((player) => {
|
||||||
|
const ar = String(player.nameAr || '')
|
||||||
|
const en = String(player.nameEn || '').toLowerCase()
|
||||||
|
return ar.includes(query) || en.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedPlayers = computed(() => {
|
||||||
|
const items = [...filteredPlayers.value]
|
||||||
|
items.sort((a, b) => comparePlayers(a, b, props.playerSort))
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedCards = computed(() => {
|
||||||
|
const groups = []
|
||||||
|
const groupMap = new Map()
|
||||||
|
|
||||||
|
for (const raw of props.assignableGroups || []) {
|
||||||
|
const code = props.normalizedGroupCode(raw)
|
||||||
|
if (!code || groupMap.has(code)) continue
|
||||||
|
groupMap.set(code, [])
|
||||||
|
groups.push(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const player of sortedPlayers.value) {
|
||||||
|
const code = props.normalizedGroupCode(player.groupCode)
|
||||||
|
if (!code) continue
|
||||||
|
if (!groupMap.has(code)) {
|
||||||
|
groupMap.set(code, [])
|
||||||
|
groups.push(code)
|
||||||
|
}
|
||||||
|
groupMap.get(code).push(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unassigned = sortedPlayers.value.filter((player) => !props.normalizedGroupCode(player.groupCode))
|
||||||
|
|
||||||
|
const cards = groups.map((code) => ({
|
||||||
|
code,
|
||||||
|
key: groupVisualKey(code),
|
||||||
|
label: `${props.t('labels.group')} ${code}`,
|
||||||
|
players: groupMap.get(code) || [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
cards.push({
|
||||||
|
code: '',
|
||||||
|
key: 'u',
|
||||||
|
label: props.t('labels.unassigned'),
|
||||||
|
players: unassigned,
|
||||||
|
})
|
||||||
|
|
||||||
|
return cards
|
||||||
|
})
|
||||||
|
|
||||||
|
const collapsedGroups = ref({})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
groupedCards,
|
||||||
|
(cards) => {
|
||||||
|
const next = {}
|
||||||
|
for (const card of cards) {
|
||||||
|
next[card.code || 'U'] = collapsedGroups.value[card.code || 'U'] || false
|
||||||
|
}
|
||||||
|
collapsedGroups.value = next
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function isGroupCollapsed(code) {
|
||||||
|
return Boolean(collapsedGroups.value[code || 'U'])
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGroup(code) {
|
||||||
|
const key = code || 'U'
|
||||||
|
collapsedGroups.value[key] = !collapsedGroups.value[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparePlayers(a, b, sort) {
|
||||||
|
if (sort === 'nameAr') {
|
||||||
|
return String(a.nameAr || '').localeCompare(String(b.nameAr || ''), 'ar') || a.id - b.id
|
||||||
|
}
|
||||||
|
if (sort === 'nameEn') {
|
||||||
|
return String(a.nameEn || '').localeCompare(String(b.nameEn || ''), 'en') || a.id - b.id
|
||||||
|
}
|
||||||
|
if (sort === 'group') {
|
||||||
|
const ga = props.normalizedGroupCode(a.groupCode)
|
||||||
|
const gb = props.normalizedGroupCode(b.groupCode)
|
||||||
|
if (ga !== gb) {
|
||||||
|
if (!ga) return 1
|
||||||
|
if (!gb) return -1
|
||||||
|
return ga.localeCompare(gb)
|
||||||
|
}
|
||||||
|
return a.id - b.id
|
||||||
|
}
|
||||||
|
return a.id - b.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupVisualKey(code) {
|
||||||
|
const normalized = props.normalizedGroupCode(code)
|
||||||
|
if (normalized.startsWith('A')) return 'a'
|
||||||
|
if (normalized.startsWith('B')) return 'b'
|
||||||
|
if (normalized.startsWith('C')) return 'c'
|
||||||
|
if (normalized.startsWith('D')) return 'd'
|
||||||
|
return 'u'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
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>
|
||||||
340
frontend/src/components/admin/ScoreStageEditor.vue
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="stage-filter-bar" v-if="showFilter">
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
:value="filterText"
|
||||||
|
:placeholder="t('actions.searchPlayer')"
|
||||||
|
@input="$emit('update:filter', $event.target.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="section in groupedSections" :key="'section-' + stage + '-' + section.key" class="score-section-block" :class="{ 'tie-break': tieBreakSectionMode }" :style="section.style">
|
||||||
|
<button class="score-section-toggle" type="button" @click="toggleSection(section.key)">
|
||||||
|
<span class="score-section-title">{{ section.label }}</span>
|
||||||
|
<span class="score-section-meta">{{ section.rows.length }} • {{ isCollapsed(section.key) ? '+' : '−' }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-if="!isCollapsed(section.key)">
|
||||||
|
<div class="table-wrap desktop-score-table">
|
||||||
|
<table class="score-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{{ t('table.competitor') }}</th>
|
||||||
|
<th v-if="showGroup">{{ t('table.group') }}</th>
|
||||||
|
<th v-if="showScoreBeforeInput">{{ t('table.score') }}</th>
|
||||||
|
<th :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">{{ inputLabel }}</th>
|
||||||
|
<th v-if="showProofControls">{{ t('table.verification') }}</th>
|
||||||
|
<th v-if="showRank">{{ t('table.rank') }}</th>
|
||||||
|
<th v-if="showSeed">{{ t('table.seed') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(row, index) in section.rows"
|
||||||
|
:key="'desk-' + stage + '-' + section.key + '-' + row.playerId"
|
||||||
|
class="score-group-row"
|
||||||
|
:style="scoreRowStyleFor(row, section)"
|
||||||
|
>
|
||||||
|
<td class="mono">{{ index + 1 }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td v-if="showGroup" class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
|
||||||
|
<td v-if="showScoreBeforeInput" class="mono strong">{{ row.score }}</td>
|
||||||
|
<td :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">
|
||||||
|
<input
|
||||||
|
class="score-input"
|
||||||
|
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(stage, section) }"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
:value="scoreInputValue(stage, row.playerId)"
|
||||||
|
@focus="onScoreFocus(stage, row.playerId)"
|
||||||
|
@input="onScoreInput(stage, row.playerId, $event)"
|
||||||
|
@blur="onScoreCommit(stage, row.playerId)"
|
||||||
|
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
|
||||||
|
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-score-cards">
|
||||||
|
<article
|
||||||
|
v-for="(row, index) in section.rows"
|
||||||
|
:key="'mob-' + stage + '-' + section.key + '-' + row.playerId"
|
||||||
|
class="score-card score-group-card"
|
||||||
|
:style="scoreRowStyleFor(row, section)"
|
||||||
|
>
|
||||||
|
<div class="score-card-head">
|
||||||
|
<div class="competitor-cell compact">
|
||||||
|
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||||
|
<div>
|
||||||
|
<p class="name-main">{{ displayName(row) }}</p>
|
||||||
|
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mono">#{{ index + 1 }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="score-card-meta">
|
||||||
|
<span v-if="showGroup">{{ t('table.group') }}: {{ row.groupCode || t('labels.unassigned') }}</span>
|
||||||
|
<span v-if="showScoreBeforeInput">{{ t('table.score') }}: {{ row.score }}</span>
|
||||||
|
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
|
||||||
|
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="score-label" :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">{{ inputLabel }}</label>
|
||||||
|
<input
|
||||||
|
class="score-input"
|
||||||
|
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(stage, section) }"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
:value="scoreInputValue(stage, row.playerId)"
|
||||||
|
@focus="onScoreFocus(stage, row.playerId)"
|
||||||
|
@input="onScoreInput(stage, row.playerId, $event)"
|
||||||
|
@blur="onScoreCommit(stage, row.playerId)"
|
||||||
|
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="showProofControls" class="proof-actions mobile-proof-actions">
|
||||||
|
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
|
||||||
|
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
|
||||||
|
{{ t('actions.removeProof') }}
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
v-if="hasScoreProof(stage, row.playerId)"
|
||||||
|
class="proof-thumb"
|
||||||
|
:src="scoreProofFor(stage, row.playerId)"
|
||||||
|
:alt="t('table.verification')"
|
||||||
|
@click="openProofPreview(stage, row.playerId)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { groupThemeStyleForKey, scoreGroupStyle, scoreValueStyle } from '../../utils/scoreGroupTheme'
|
||||||
|
import { normalizedGroupCode } from '../../utils/groups'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
t: { type: Function, required: true },
|
||||||
|
stage: { type: String, required: true },
|
||||||
|
rows: { type: Array, required: true },
|
||||||
|
showFilter: { type: Boolean, default: true },
|
||||||
|
filterText: { type: String, default: '' },
|
||||||
|
showGroup: { type: Boolean, default: false },
|
||||||
|
showRank: { type: Boolean, default: false },
|
||||||
|
showSeed: { type: Boolean, default: false },
|
||||||
|
colorMode: { type: String, default: 'group' },
|
||||||
|
showScoreBeforeInput: { type: Boolean, default: false },
|
||||||
|
inputLabel: { type: String, required: true },
|
||||||
|
sectionByGroup: { type: Boolean, default: false },
|
||||||
|
tieBreakSectionMode: { type: Boolean, default: false },
|
||||||
|
showProofControls: { type: Boolean, default: true },
|
||||||
|
playerImage: { type: Function, required: true },
|
||||||
|
displayName: { type: Function, required: true },
|
||||||
|
secondaryName: { type: Function, required: true },
|
||||||
|
scoreInputValue: { type: Function, required: true },
|
||||||
|
scoreInputInvalid: { type: Function, required: true },
|
||||||
|
onScoreFocus: { type: Function, required: true },
|
||||||
|
onScoreInput: { type: Function, required: true },
|
||||||
|
onScoreCommit: { type: Function, required: true },
|
||||||
|
hasScoreProof: { type: Function, required: true },
|
||||||
|
scoreProofFor: { type: Function, required: true },
|
||||||
|
openScoreProofUploader: { type: Function, required: true },
|
||||||
|
removeScoreProof: { type: Function, required: true },
|
||||||
|
openProofPreview: { type: Function, required: true },
|
||||||
|
requestScoreAdvice: { type: Function, required: true },
|
||||||
|
highlightStage: { type: String, default: '' },
|
||||||
|
highlightGroup: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['update:filter'])
|
||||||
|
|
||||||
|
const collapsedSections = ref({})
|
||||||
|
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const query = String(props.filterText || '').trim().toLowerCase()
|
||||||
|
if (!query) return props.rows
|
||||||
|
return props.rows.filter((row) => {
|
||||||
|
const ar = String(row.nameAr || '')
|
||||||
|
const en = String(row.nameEn || '').toLowerCase()
|
||||||
|
return ar.includes(query) || en.includes(query)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedSections = computed(() => {
|
||||||
|
if (!props.sectionByGroup) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
label: props.t('labels.allPlayers'),
|
||||||
|
rows: filteredRows.value,
|
||||||
|
style: null,
|
||||||
|
},
|
||||||
|
].filter((section) => section.rows.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buckets = new Map()
|
||||||
|
for (const row of filteredRows.value) {
|
||||||
|
const key = sectionKeyForRow(row)
|
||||||
|
if (!buckets.has(key)) {
|
||||||
|
buckets.set(key, {
|
||||||
|
key,
|
||||||
|
label: sectionLabelForKey(key),
|
||||||
|
rows: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
buckets.get(key).rows.push(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...buckets.values()]
|
||||||
|
.sort((a, b) => sectionSortValue(a.key) - sectionSortValue(b.key) || String(a.key).localeCompare(String(b.key)))
|
||||||
|
.map((section) => ({
|
||||||
|
...section,
|
||||||
|
style: sectionStyleFor(section.key, section.rows[0]),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
groupedSections,
|
||||||
|
(sections) => {
|
||||||
|
const next = {}
|
||||||
|
for (const section of sections) {
|
||||||
|
next[section.key] = collapsedSections.value[section.key] || false
|
||||||
|
}
|
||||||
|
collapsedSections.value = next
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function sectionKeyForRow(row) {
|
||||||
|
const boardGroup = String(row?._boardGroupKey || '').trim()
|
||||||
|
if (boardGroup) return `B${boardGroup}`
|
||||||
|
|
||||||
|
if (props.tieBreakSectionMode) {
|
||||||
|
const tieGroup = Number.parseInt(String(row?.scoreGroup || ''), 10)
|
||||||
|
if (Number.isFinite(tieGroup) && tieGroup > 0) return `T${tieGroup}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalGroup = Number.parseInt(String(row?.finalGroup || ''), 10)
|
||||||
|
if (Number.isFinite(finalGroup) && finalGroup > 0) return `F${finalGroup}`
|
||||||
|
|
||||||
|
const group = normalizedGroupCode(row?.groupCode || '')
|
||||||
|
if (group) return `G${group}`
|
||||||
|
|
||||||
|
return 'U'
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionLabelForKey(key) {
|
||||||
|
if (key === 'U') return `${props.t('labels.group')} ${props.t('labels.unassigned')}`
|
||||||
|
if (key.startsWith('B')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||||
|
if (key.startsWith('T')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||||
|
if (key.startsWith('F')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||||
|
if (key.startsWith('G')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||||
|
return `${props.t('labels.group')} ${key}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionSortValue(key) {
|
||||||
|
if (key === 'U') return 9999
|
||||||
|
if (key.startsWith('B')) {
|
||||||
|
const raw = key.slice(1)
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
if (Number.isFinite(parsed) && parsed > 0) return parsed
|
||||||
|
const code = String(raw).toUpperCase()
|
||||||
|
if (code === 'A') return 1
|
||||||
|
if (code === 'B') return 2
|
||||||
|
if (code === 'C') return 3
|
||||||
|
if (code === 'D') return 4
|
||||||
|
return 5000 + hashToHue(code)
|
||||||
|
}
|
||||||
|
if (key.startsWith('T') || key.startsWith('F')) return Number.parseInt(key.slice(1), 10) || 9998
|
||||||
|
if (key.startsWith('G')) {
|
||||||
|
const raw = key.slice(1)
|
||||||
|
const code = String(raw || '').trim().toUpperCase()
|
||||||
|
if (code === 'A') return 1
|
||||||
|
if (code === 'B') return 2
|
||||||
|
if (code === 'C') return 3
|
||||||
|
if (code === 'D') return 4
|
||||||
|
return 5000 + hashToHue(code)
|
||||||
|
}
|
||||||
|
return 8000 + hashToHue(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionStyleFor(key, sampleRow) {
|
||||||
|
if (key.startsWith('B')) return groupThemeStyleForKey(key)
|
||||||
|
if (props.tieBreakSectionMode) {
|
||||||
|
return groupThemeStyleForKey(key, { tieBreak: true })
|
||||||
|
}
|
||||||
|
return scoreGroupStyle(sampleRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreRowStyleFor(row, section) {
|
||||||
|
if (section?.key?.startsWith('B') && section?.style) return section.style
|
||||||
|
if (props.tieBreakSectionMode && section?.style) return section.style
|
||||||
|
if (props.colorMode === 'score') return scoreValueStyle(row?.score)
|
||||||
|
return scoreGroupStyle(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCollapsed(sectionKey) {
|
||||||
|
return Boolean(collapsedSections.value[sectionKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSection(sectionKey) {
|
||||||
|
collapsedSections.value[sectionKey] = !collapsedSections.value[sectionKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHighlightedStage(stageName, section) {
|
||||||
|
const stageMatch = String(props.highlightStage || '').trim().toLowerCase() === String(stageName || '').trim().toLowerCase()
|
||||||
|
if (!stageMatch) return false
|
||||||
|
const wanted = String(props.highlightGroup || '').trim().toUpperCase()
|
||||||
|
if (!wanted) return true
|
||||||
|
return sectionGroupKey(section) === wanted
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionGroupKey(section) {
|
||||||
|
const key = String(section?.key || '').trim()
|
||||||
|
if (!key) return ''
|
||||||
|
if (key === 'U') return 'UNASSIGNED'
|
||||||
|
if (key.startsWith('B') || key.startsWith('F') || key.startsWith('G') || key.startsWith('T')) {
|
||||||
|
return String(key.slice(1)).trim().toUpperCase()
|
||||||
|
}
|
||||||
|
return key.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashToHue(value) {
|
||||||
|
const input = String(value || 'U')
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
hash = (hash * 33 + input.charCodeAt(i)) % 360
|
||||||
|
}
|
||||||
|
return (hash + 360) % 360
|
||||||
|
}
|
||||||
|
</script>
|
||||||
455
frontend/src/constants/i18n.js
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
export const I18N = {
|
||||||
|
ar: {
|
||||||
|
titleFallback: 'بطولة دويتوايلر للرماية',
|
||||||
|
subtitle: 'فئة المسدس · مسافات: 15م ← 20م ← 25م',
|
||||||
|
viewMode: 'وضع العرض',
|
||||||
|
adminMode: 'لوحة الإدارة',
|
||||||
|
liveMode: 'الوضع الحي',
|
||||||
|
adminLogin: 'تسجيل دخول الإدارة',
|
||||||
|
adminLoginDesc: 'أدخل بيانات الإدارة للتحكم الكامل في البطولة.',
|
||||||
|
adminPanel: 'لوحة التحكم الإدارية',
|
||||||
|
adminPanelDesc: 'إدارة اللاعبين، التوزيع، وإدخال النتائج لكل المراحل.',
|
||||||
|
defaultCredentials: 'بيانات الإدارة الافتراضية: datwyler / datwyler',
|
||||||
|
labels: {
|
||||||
|
liveTracker: 'LIVE TRACKER',
|
||||||
|
mode: 'الوضع',
|
||||||
|
language: 'اللغة',
|
||||||
|
fullscreen: 'ملء الشاشة',
|
||||||
|
lastSync: 'آخر مزامنة',
|
||||||
|
loading: 'جاري تحميل بيانات البطولة...',
|
||||||
|
players: 'لاعب',
|
||||||
|
group: 'المجموعة',
|
||||||
|
unassigned: 'غير معين',
|
||||||
|
emptyLive: 'لا يوجد لاعبين في هذه المجموعة',
|
||||||
|
allPlayers: 'جميع اللاعبين',
|
||||||
|
top12: 'أفضل 12 متأهل',
|
||||||
|
finalist: 'متأهل',
|
||||||
|
tieBreak: 'كسر تعادل',
|
||||||
|
notFinalist: 'غير متأهل',
|
||||||
|
noPlayers: 'لا يوجد لاعبين بعد',
|
||||||
|
noFinalists: 'لا يوجد متأهلون بعد',
|
||||||
|
finalGroup1: 'المجموعة النهائية 1 (المراكز 1-6)',
|
||||||
|
finalGroup2: 'المجموعة النهائية 2 (المراكز 7-12)',
|
||||||
|
finalGroupSeeds1: 'المجموعة النهائية (المراكز 1-6)',
|
||||||
|
finalGroupSeeds2: 'المجموعة النهائية (المراكز 7-12)',
|
||||||
|
tieSlots: 'عدد المقاعد المتاحة من كسر التعادل',
|
||||||
|
waiting: 'بانتظار النتيجة',
|
||||||
|
viewProofInView: 'إظهار صور إثبات النتيجة في وضع العرض',
|
||||||
|
liveModeVisibility: 'إعدادات شاشة الوضع الحي',
|
||||||
|
activeLiveView: 'العرض النشط في الوضع الحي',
|
||||||
|
rotationIntervalSec: 'مدة التدوير (ثانية)',
|
||||||
|
rotationPlayerCount: 'عدد اللاعبين لكل صفحة تدوير',
|
||||||
|
liveViewGroup: 'شاشة المجموعات الحية',
|
||||||
|
liveViewPrelimTie: 'كسر تعادل التمهيدي',
|
||||||
|
liveViewPrelimOverall: 'الترتيب العام للتمهيدي',
|
||||||
|
liveViewFinalGroups: 'شاشة مجموعات النهائي',
|
||||||
|
liveViewFinalTie: 'كسر تعادل النهائي',
|
||||||
|
liveViewFinalOverall: 'الترتيب العام للنهائي',
|
||||||
|
liveViewPodium: 'منصة التتويج',
|
||||||
|
showGroupLive: 'إظهار شاشة المجموعات الحية',
|
||||||
|
showPrelimTie: 'إظهار كسر تعادل التمهيدي',
|
||||||
|
showPrelimOverall: 'إظهار ترتيب التمهيدي العام',
|
||||||
|
showFinalGroups: 'إظهار مجموعات النهائي',
|
||||||
|
showFinalTie: 'إظهار كسر تعادل النهائي',
|
||||||
|
showPodium: 'إظهار منصة التتويج',
|
||||||
|
groupDisplayMode: 'عرض المجموعات',
|
||||||
|
finalDisplayMode: 'عرض مجموعات النهائي',
|
||||||
|
fixedGroup: 'مجموعة ثابتة',
|
||||||
|
fixedFinalGroup: 'المجموعة النهائية الثابتة',
|
||||||
|
sortBy: 'الترتيب',
|
||||||
|
zoom: 'التكبير',
|
||||||
|
currentScore: 'النتيجة الحالية',
|
||||||
|
aiSuggestedScore: 'النتيجة المقترحة',
|
||||||
|
confidence: 'مستوى الثقة',
|
||||||
|
stageControl: 'المرحلة الحالية',
|
||||||
|
stagePhase: 'الطور',
|
||||||
|
stageGroup: 'المجموعة',
|
||||||
|
stageRound: 'الجولة',
|
||||||
|
currentStage: 'المرحلة الجارية',
|
||||||
|
lastStage: 'آخر مرحلة',
|
||||||
|
startedAt: 'بدأت',
|
||||||
|
endedAt: 'انتهت',
|
||||||
|
noCurrentStage: 'لا توجد مرحلة جارية',
|
||||||
|
noLastStage: 'لا توجد مرحلة سابقة',
|
||||||
|
phasePreliminary: 'التمهيدي',
|
||||||
|
phasePrelimTie: 'كسر تعادل التمهيدي',
|
||||||
|
phaseFinal: 'النهائي',
|
||||||
|
phaseFinalTie: 'كسر تعادل النهائي',
|
||||||
|
groupAssignment: 'توزيع المجموعات',
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
groupsTitle: 'عرض اللاعبين والمجموعات',
|
||||||
|
groupsSubtitle: 'شاشة نظيفة للمتابعة المباشرة حسب المجموعة.',
|
||||||
|
liveTitle: 'العرض الحي للمجموعة',
|
||||||
|
liveSubtitle: 'تدوير تلقائي بين المجموعات المسجلة كل 5 ثوان.',
|
||||||
|
overallTitle: 'الترتيب العام للمرحلة التمهيدية',
|
||||||
|
overallSubtitle: 'يتم تمييز أفضل 12 متأهل للنهائي.',
|
||||||
|
finalTitle: 'المرحلة النهائية',
|
||||||
|
finalSubtitle: 'تقسيم المتأهلين إلى مجموعتين حسب الترتيب.',
|
||||||
|
finalRanking: 'الترتيب النهائي',
|
||||||
|
podiumTitle: 'منصة التتويج',
|
||||||
|
podiumSubtitle: 'المراكز الثلاثة الأولى بعد فك أي تعادل.',
|
||||||
|
playersTitle: 'إدارة اللاعبين',
|
||||||
|
playersSubtitle: 'إضافة/تعديل/حذف لاعب، وتعديل الاسم والصورة والمجموعة.',
|
||||||
|
groupsConfigPlaceholder: 'المجموعات الأساسية (مثال: A,B,C,D)',
|
||||||
|
preliminaryAdminTitle: 'إدخال نتائج المرحلة التمهيدية',
|
||||||
|
preliminaryAdminSubtitle: 'إدخال يدوي للنتيجة التمهيدية لكل لاعب.',
|
||||||
|
prelimTieTitle: 'إدخال كسر تعادل التأهل (أفضل 12)',
|
||||||
|
prelimTieSubtitle: 'يظهر فقط اللاعبين المتعادلين على حد التأهل.',
|
||||||
|
finalAdminTitle: 'إدخال نتائج المرحلة النهائية',
|
||||||
|
finalAdminSubtitle: 'إدخال نتائج النهائي للمجموعتين.',
|
||||||
|
finalTieTitle: 'إدخال كسر تعادل منصة التتويج',
|
||||||
|
finalTieSubtitle: 'يظهر فقط عند تعادل المراكز 1-3.',
|
||||||
|
profileCropTitle: 'تعديل صورة اللاعب',
|
||||||
|
profileCropSubtitle: 'حرّك وكبّر الصورة لتناسب الإطار الدائري قبل الحفظ.',
|
||||||
|
aiAdvisorTitle: 'مساعد الذكاء الاصطناعي للنتيجة',
|
||||||
|
liveModeTitle: 'شاشة البطولة المباشرة',
|
||||||
|
liveModeSubtitle: 'عرض مخصص للشاشات أثناء الفعالية مع تحديث لحظي.',
|
||||||
|
prelimRoundsTitle: 'الجولات التمهيدية',
|
||||||
|
finalRoundsTitle: 'جولات النهائي',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
competitor: 'اللاعب',
|
||||||
|
group: 'المجموعة',
|
||||||
|
rank: 'الترتيب',
|
||||||
|
position: 'الموضع',
|
||||||
|
score: 'النتيجة',
|
||||||
|
total: 'المجموع',
|
||||||
|
round1: 'جولة 1',
|
||||||
|
round2: 'جولة 2',
|
||||||
|
round3: 'جولة 3',
|
||||||
|
tieScore: 'نتيجة كسر التعادل',
|
||||||
|
verification: 'التحقق',
|
||||||
|
status: 'الحالة',
|
||||||
|
seed: 'التصنيف',
|
||||||
|
medal: 'الميدالية',
|
||||||
|
actions: 'الإجراءات',
|
||||||
|
arabicName: 'الاسم بالعربية',
|
||||||
|
englishName: 'الاسم بالإنجليزية',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
refresh: 'تحديث',
|
||||||
|
login: 'دخول',
|
||||||
|
logout: 'تسجيل خروج',
|
||||||
|
updateGroups: 'تحديث المجموعات',
|
||||||
|
liveRotate: 'دوران تلقائي',
|
||||||
|
liveFixed: 'مجموعة محددة',
|
||||||
|
uploadProof: 'رفع إثبات',
|
||||||
|
replaceProof: 'استبدال الإثبات',
|
||||||
|
removeProof: 'حذف الإثبات',
|
||||||
|
addPlayer: 'إضافة لاعب',
|
||||||
|
removeImage: 'حذف الصورة',
|
||||||
|
delete: 'حذف',
|
||||||
|
resetScores: 'تصفير نتائج المرحلة',
|
||||||
|
resetFinalGroupBySeed: 'إعادة مجموعات النهائي حسب التصنيف (1-6 / 7-12)',
|
||||||
|
resetOnlyScores: 'تصفير النتائج فقط',
|
||||||
|
resetScoresAndProofs: 'تصفير النتائج وحذف الإثبات',
|
||||||
|
randomEvenGroups: 'توزيع عشوائي متوازن',
|
||||||
|
searchPlayer: 'بحث بالاسم العربي أو الإنجليزي',
|
||||||
|
cancel: 'إلغاء',
|
||||||
|
convertArToEn: 'تحويل عربي → إنجليزي',
|
||||||
|
convertEnToAr: 'تحويل إنجليزي → عربي',
|
||||||
|
sortById: 'حسب الرقم',
|
||||||
|
sortByArabic: 'حسب الاسم العربي',
|
||||||
|
sortByEnglish: 'حسب الاسم الإنجليزي',
|
||||||
|
sortByGroup: 'حسب المجموعة',
|
||||||
|
aiAdvisor: 'اقتراح AI',
|
||||||
|
quickApplyAi: 'تطبيق سريع AI',
|
||||||
|
reAnalyze: 'إعادة التحليل',
|
||||||
|
applySuggestedScore: 'اعتماد النتيجة المقترحة',
|
||||||
|
applyCrop: 'تطبيق القص',
|
||||||
|
resetPosition: 'إعادة الضبط',
|
||||||
|
liveModeRotate: 'تدوير تلقائي',
|
||||||
|
liveModeFixed: 'ثابت',
|
||||||
|
enterFullscreen: 'دخول ملء الشاشة',
|
||||||
|
exitFullscreen: 'الخروج من ملء الشاشة',
|
||||||
|
startStage: 'بدء المرحلة',
|
||||||
|
endStage: 'إنهاء المرحلة',
|
||||||
|
goToStageScoring: 'الانتقال إلى إدخال نتائج المرحلة',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
username: 'اسم المستخدم',
|
||||||
|
password: 'كلمة المرور',
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
groups: 'المجموعات',
|
||||||
|
live: 'عرض حي',
|
||||||
|
overall: 'الترتيب العام',
|
||||||
|
final: 'النهائي',
|
||||||
|
podium: 'التتويج',
|
||||||
|
players: 'اللاعبون',
|
||||||
|
preliminary: 'التمهيدي',
|
||||||
|
prelimTie: 'كسر تعادل التأهل',
|
||||||
|
finalTie: 'كسر تعادل التتويج',
|
||||||
|
liveModeAdmin: 'إعدادات الوضع الحي',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
saved: 'تم الحفظ بنجاح.',
|
||||||
|
mustProvideNames: 'يرجى إدخال الاسم بالعربية والإنجليزية.',
|
||||||
|
noPrelimTie: 'لا يوجد تعادل على حد التأهل.',
|
||||||
|
noFinalTie: 'لا يوجد تعادل في المراكز 1-3.',
|
||||||
|
prelimTieUnresolved: 'تعادل التأهل غير محسوم. أدخل نتائج كسر التعادل لتحديد أفضل 12.',
|
||||||
|
finalTieUnresolved: 'تعادل منصة التتويج غير محسوم. أكمل إدخال كسر التعادل.',
|
||||||
|
confirmDelete: 'هل تريد حذف اللاعب؟',
|
||||||
|
confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟',
|
||||||
|
resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟',
|
||||||
|
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 100.',
|
||||||
|
unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.',
|
||||||
|
errorPrefix: 'حدث خطأ',
|
||||||
|
noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.',
|
||||||
|
noNameToConvert: 'لا يوجد اسم للتحويل.',
|
||||||
|
aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...',
|
||||||
|
noProofForAi: 'لا توجد صورة إثبات لتحليلها.',
|
||||||
|
noGroupForPhase: 'لا توجد مجموعات متاحة لهذا الطور.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
titleFallback: 'Datwyler Shooting Event',
|
||||||
|
subtitle: 'Pistol class · Distances: 15m ← 20m ← 25m',
|
||||||
|
viewMode: 'View Mode',
|
||||||
|
adminMode: 'Admin Panel',
|
||||||
|
liveMode: 'Live Mode',
|
||||||
|
adminLogin: 'Admin Login',
|
||||||
|
adminLoginDesc: 'Enter admin credentials for full tournament control.',
|
||||||
|
adminPanel: 'Admin Control Panel',
|
||||||
|
adminPanelDesc: 'Manage players, assignments, and scoring for all stages.',
|
||||||
|
defaultCredentials: 'Default admin credentials: datwyler / datwyler',
|
||||||
|
labels: {
|
||||||
|
liveTracker: 'LIVE TRACKER',
|
||||||
|
mode: 'Mode',
|
||||||
|
language: 'Language',
|
||||||
|
fullscreen: 'Fullscreen',
|
||||||
|
lastSync: 'Last sync',
|
||||||
|
loading: 'Loading tournament data...',
|
||||||
|
players: 'Players',
|
||||||
|
group: 'Group',
|
||||||
|
unassigned: 'Unassigned',
|
||||||
|
emptyLive: 'No players in this group',
|
||||||
|
allPlayers: 'All players',
|
||||||
|
top12: 'Top 12 finalists',
|
||||||
|
finalist: 'Finalist',
|
||||||
|
tieBreak: 'Tie-break',
|
||||||
|
notFinalist: 'Not finalist',
|
||||||
|
noPlayers: 'No players yet',
|
||||||
|
noFinalists: 'No finalists yet',
|
||||||
|
finalGroup1: 'Final Group 1 (Seeds 1-6)',
|
||||||
|
finalGroup2: 'Final Group 2 (Seeds 7-12)',
|
||||||
|
finalGroupSeeds1: 'Final Group Seeds 1-6',
|
||||||
|
finalGroupSeeds2: 'Final Group Seeds 7-12',
|
||||||
|
tieSlots: 'Tie-break slots',
|
||||||
|
waiting: 'Waiting',
|
||||||
|
viewProofInView: 'Allow proof images in view mode',
|
||||||
|
liveModeVisibility: 'Live mode screen controls',
|
||||||
|
activeLiveView: 'Active live view',
|
||||||
|
rotationIntervalSec: 'Rotation interval (sec)',
|
||||||
|
rotationPlayerCount: 'Players per rotation page',
|
||||||
|
liveViewGroup: 'Live group board',
|
||||||
|
liveViewPrelimTie: 'Preliminary tie-break',
|
||||||
|
liveViewPrelimOverall: 'Preliminary overall',
|
||||||
|
liveViewFinalGroups: 'Final groups board',
|
||||||
|
liveViewFinalTie: 'Final tie-break',
|
||||||
|
liveViewFinalOverall: 'Final overall ranking',
|
||||||
|
liveViewPodium: 'Podium',
|
||||||
|
showGroupLive: 'Show live group board',
|
||||||
|
showPrelimTie: 'Show preliminary tie-break',
|
||||||
|
showPrelimOverall: 'Show preliminary overall ranking',
|
||||||
|
showFinalGroups: 'Show final groups board',
|
||||||
|
showFinalTie: 'Show final tie-break',
|
||||||
|
showPodium: 'Show podium',
|
||||||
|
groupDisplayMode: 'Group board mode',
|
||||||
|
finalDisplayMode: 'Final board mode',
|
||||||
|
fixedGroup: 'Fixed group',
|
||||||
|
fixedFinalGroup: 'Fixed final group',
|
||||||
|
sortBy: 'Sort by',
|
||||||
|
zoom: 'Zoom',
|
||||||
|
currentScore: 'Current score',
|
||||||
|
aiSuggestedScore: 'AI suggested score',
|
||||||
|
confidence: 'Confidence',
|
||||||
|
stageControl: 'Current Stage Control',
|
||||||
|
stagePhase: 'Phase',
|
||||||
|
stageGroup: 'Group',
|
||||||
|
stageRound: 'Round',
|
||||||
|
currentStage: 'Current Stage',
|
||||||
|
lastStage: 'Last Stage',
|
||||||
|
startedAt: 'Started',
|
||||||
|
endedAt: 'Ended',
|
||||||
|
noCurrentStage: 'No active stage',
|
||||||
|
noLastStage: 'No previous stage',
|
||||||
|
phasePreliminary: 'Preliminary',
|
||||||
|
phasePrelimTie: 'Preliminary Tie-Break',
|
||||||
|
phaseFinal: 'Final',
|
||||||
|
phaseFinalTie: 'Final Tie-Break',
|
||||||
|
groupAssignment: 'Group Assignment',
|
||||||
|
},
|
||||||
|
sections: {
|
||||||
|
groupsTitle: 'Players & Groups Overview',
|
||||||
|
groupsSubtitle: 'Clean view screen for live grouping.',
|
||||||
|
liveTitle: 'Live Group Screen',
|
||||||
|
liveSubtitle: 'Automatic rotation through registered groups every 5 seconds.',
|
||||||
|
overallTitle: 'Preliminary Overall Ranking',
|
||||||
|
overallSubtitle: 'Top 12 finalists are highlighted.',
|
||||||
|
finalTitle: 'Final Stage',
|
||||||
|
finalSubtitle: 'Finalists are split into two groups by rank.',
|
||||||
|
finalRanking: 'Final Ranking',
|
||||||
|
podiumTitle: 'Podium',
|
||||||
|
podiumSubtitle: 'Top 3 after tie-break resolution.',
|
||||||
|
playersTitle: 'Players Management',
|
||||||
|
playersSubtitle: 'Add/update/remove players, names, images, and group assignment.',
|
||||||
|
groupsConfigPlaceholder: 'Primary groups (example: A,B,C,D)',
|
||||||
|
preliminaryAdminTitle: 'Preliminary Scoring',
|
||||||
|
preliminaryAdminSubtitle: 'Manually input each player preliminary score.',
|
||||||
|
prelimTieTitle: 'Qualification Tie-Break Scoring (Top 12)',
|
||||||
|
prelimTieSubtitle: 'Only players tied at qualification cutoff are shown.',
|
||||||
|
finalAdminTitle: 'Final Stage Scoring',
|
||||||
|
finalAdminSubtitle: 'Input final scores for both final groups.',
|
||||||
|
finalTieTitle: 'Podium Tie-Break Scoring',
|
||||||
|
finalTieSubtitle: 'Shown only when places 1-3 are tied.',
|
||||||
|
profileCropTitle: 'Adjust Player Photo',
|
||||||
|
profileCropSubtitle: 'Move and zoom to fit the circular avatar before saving.',
|
||||||
|
aiAdvisorTitle: 'AI Score Advisor',
|
||||||
|
liveModeTitle: 'Event Live Screen',
|
||||||
|
liveModeSubtitle: 'Monitor-ready display with real-time updates.',
|
||||||
|
prelimRoundsTitle: 'Preliminary Rounds',
|
||||||
|
finalRoundsTitle: 'Final Rounds',
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
competitor: 'Player',
|
||||||
|
group: 'Group',
|
||||||
|
rank: 'Rank',
|
||||||
|
position: 'Position',
|
||||||
|
score: 'Score',
|
||||||
|
total: 'Total',
|
||||||
|
round1: 'Round 1',
|
||||||
|
round2: 'Round 2',
|
||||||
|
round3: 'Round 3',
|
||||||
|
tieScore: 'Tie-Break Score',
|
||||||
|
verification: 'Verification',
|
||||||
|
status: 'Status',
|
||||||
|
seed: 'Seed',
|
||||||
|
medal: 'Medal',
|
||||||
|
actions: 'Actions',
|
||||||
|
arabicName: 'Arabic Name',
|
||||||
|
englishName: 'English Name',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
refresh: 'Refresh',
|
||||||
|
login: 'Login',
|
||||||
|
logout: 'Logout',
|
||||||
|
updateGroups: 'Update Groups',
|
||||||
|
liveRotate: 'Auto Rotation',
|
||||||
|
liveFixed: 'Fixed Group',
|
||||||
|
uploadProof: 'Upload Proof',
|
||||||
|
replaceProof: 'Replace Proof',
|
||||||
|
removeProof: 'Remove Proof',
|
||||||
|
addPlayer: 'Add Player',
|
||||||
|
removeImage: 'Remove Image',
|
||||||
|
delete: 'Delete',
|
||||||
|
resetScores: 'Reset Stage Scores',
|
||||||
|
resetFinalGroupBySeed: 'Reset Final Groups By Seed (1-6 / 7-12)',
|
||||||
|
resetOnlyScores: 'Reset scores only',
|
||||||
|
resetScoresAndProofs: 'Reset scores and proofs',
|
||||||
|
randomEvenGroups: 'Random even grouping',
|
||||||
|
searchPlayer: 'Search by Arabic or English name',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
convertArToEn: 'Convert Arabic → English',
|
||||||
|
convertEnToAr: 'Convert English → Arabic',
|
||||||
|
sortById: 'By ID',
|
||||||
|
sortByArabic: 'By Arabic name',
|
||||||
|
sortByEnglish: 'By English name',
|
||||||
|
sortByGroup: 'By group',
|
||||||
|
aiAdvisor: 'AI Advice',
|
||||||
|
quickApplyAi: 'Quick Apply AI',
|
||||||
|
reAnalyze: 'Re-analyze',
|
||||||
|
applySuggestedScore: 'Apply suggested score',
|
||||||
|
applyCrop: 'Apply crop',
|
||||||
|
resetPosition: 'Reset position',
|
||||||
|
liveModeRotate: 'Auto rotate',
|
||||||
|
liveModeFixed: 'Fixed',
|
||||||
|
enterFullscreen: 'Enter Fullscreen',
|
||||||
|
exitFullscreen: 'Exit Fullscreen',
|
||||||
|
startStage: 'Start Stage',
|
||||||
|
endStage: 'End Stage',
|
||||||
|
goToStageScoring: 'Go To Stage Scoring',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
username: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
groups: 'Groups',
|
||||||
|
live: 'Live',
|
||||||
|
overall: 'Overall',
|
||||||
|
final: 'Final',
|
||||||
|
podium: 'Podium',
|
||||||
|
players: 'Players',
|
||||||
|
preliminary: 'Preliminary',
|
||||||
|
prelimTie: 'Prelim Tie-Break',
|
||||||
|
finalTie: 'Final Tie-Break',
|
||||||
|
liveModeAdmin: 'Live Mode Settings',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
saved: 'Saved successfully.',
|
||||||
|
mustProvideNames: 'Arabic and English names are required.',
|
||||||
|
noPrelimTie: 'No tie at qualification cutoff.',
|
||||||
|
noFinalTie: 'No tie in places 1-3.',
|
||||||
|
prelimTieUnresolved: 'Qualification tie is unresolved. Enter tie-break scores to finalize top 12.',
|
||||||
|
finalTieUnresolved: 'Podium tie is unresolved. Complete tie-break scoring.',
|
||||||
|
confirmDelete: 'Delete this player?',
|
||||||
|
confirmReset: 'Reset this stage scores?',
|
||||||
|
resetProofPrompt: 'Also remove all proof images for this stage?',
|
||||||
|
invalidScore: 'Score must be between 0 and 100.',
|
||||||
|
unauthorized: 'Admin session expired. Please login again.',
|
||||||
|
errorPrefix: 'Error',
|
||||||
|
noGroupsConfigured: 'No primary groups configured.',
|
||||||
|
noNameToConvert: 'No name available to convert.',
|
||||||
|
aiAnalyzing: 'Analyzing image with AI...',
|
||||||
|
noProofForAi: 'No proof image available to analyze.',
|
||||||
|
noGroupForPhase: 'No groups available for this phase.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialState() {
|
||||||
|
return {
|
||||||
|
competition: { titleAr: '', titleEn: '' },
|
||||||
|
players: [],
|
||||||
|
scores: {
|
||||||
|
prelim_r1: {},
|
||||||
|
prelim_r2: {},
|
||||||
|
prelim_r3: {},
|
||||||
|
prelim_tiebreak: {},
|
||||||
|
final_r1: {},
|
||||||
|
final_r2: {},
|
||||||
|
final_tiebreak: {},
|
||||||
|
},
|
||||||
|
scoreProofs: {},
|
||||||
|
settings: {
|
||||||
|
viewProofInView: false,
|
||||||
|
liveMode: {
|
||||||
|
activeView: 'group_live',
|
||||||
|
showGroupLive: true,
|
||||||
|
groupDisplayMode: 'rotate',
|
||||||
|
groupFixedCode: '',
|
||||||
|
showPrelimTie: true,
|
||||||
|
showPrelimOverall: true,
|
||||||
|
showFinalGroups: true,
|
||||||
|
finalDisplayMode: 'rotate',
|
||||||
|
finalFixedGroup: 1,
|
||||||
|
showFinalTie: true,
|
||||||
|
showPodium: true,
|
||||||
|
rotationIntervalSec: 5,
|
||||||
|
rotationPlayerCount: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
derived: {
|
||||||
|
preliminaryRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
|
||||||
|
finalists: [],
|
||||||
|
finalGroups: { group1: [], group2: [] },
|
||||||
|
finalRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
|
||||||
|
podium: [],
|
||||||
|
},
|
||||||
|
current_stage: null,
|
||||||
|
last_stage: null,
|
||||||
|
serverTime: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/src/main.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
3337
frontend/src/style.css
Normal file
28
frontend/src/utils/groups.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export const DEFAULT_PRIMARY_GROUPS = ['A', 'B', 'C', 'D']
|
||||||
|
|
||||||
|
export function normalizedGroupCode(value) {
|
||||||
|
return String(value || '').trim().toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGroupList(raw) {
|
||||||
|
const items = String(raw || '')
|
||||||
|
.split(',')
|
||||||
|
.map((item) => normalizedGroupCode(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
return [...new Set(items)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPrimaryGroups(storage) {
|
||||||
|
const stored = storage.getItem('shooting_group_list')
|
||||||
|
const parsed = parseGroupList(stored)
|
||||||
|
return parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupKey(code) {
|
||||||
|
const normalized = normalizedGroupCode(code)
|
||||||
|
if (normalized.startsWith('A')) return 'a'
|
||||||
|
if (normalized.startsWith('B')) return 'b'
|
||||||
|
if (normalized.startsWith('C')) return 'c'
|
||||||
|
if (normalized.startsWith('D')) return 'd'
|
||||||
|
return 'u'
|
||||||
|
}
|
||||||
220
frontend/src/utils/nameTransliteration.js
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
const ARABIC_DIACRITICS = /[\u064B-\u065F\u0670\u0640]/g
|
||||||
|
|
||||||
|
const AR_TO_EN_WORD = {
|
||||||
|
محمد: 'Mohammad',
|
||||||
|
احمد: 'Ahmad',
|
||||||
|
محمود: 'Mahmoud',
|
||||||
|
عبدالرحمن: 'Abdulrahman',
|
||||||
|
عبدالله: 'Abdullah',
|
||||||
|
عبدالله: 'Abdullah',
|
||||||
|
عبدالاله: 'Abdulilah',
|
||||||
|
عمر: 'Omar',
|
||||||
|
علي: 'Ali',
|
||||||
|
خالد: 'Khaled',
|
||||||
|
خليل: 'Khalil',
|
||||||
|
يزن: 'Yazan',
|
||||||
|
يزيد: 'Yazeed',
|
||||||
|
معاذ: 'Moaz',
|
||||||
|
طارق: 'Tareq',
|
||||||
|
زيد: 'Zaid',
|
||||||
|
سامر: 'Samer',
|
||||||
|
سيف: 'Saif',
|
||||||
|
حسام: 'Hossam',
|
||||||
|
باسم: 'Bassem',
|
||||||
|
امجد: 'Amjad',
|
||||||
|
مأمون: 'Maamoun',
|
||||||
|
اياد: 'Eyad',
|
||||||
|
إياد: 'Eyad',
|
||||||
|
حمزة: 'Hamza',
|
||||||
|
حمزه: 'Hamza',
|
||||||
|
هيثم: 'Haitham',
|
||||||
|
وائل: 'Wael',
|
||||||
|
رائد: 'Raed',
|
||||||
|
فهد: 'Fahad',
|
||||||
|
فارس: 'Fares',
|
||||||
|
ناصر: 'Nasser',
|
||||||
|
جميل: 'Jameel',
|
||||||
|
}
|
||||||
|
|
||||||
|
const EN_TO_AR_WORD = {
|
||||||
|
mohammad: 'محمد',
|
||||||
|
muhammad: 'محمد',
|
||||||
|
ahmad: 'أحمد',
|
||||||
|
ahmed: 'أحمد',
|
||||||
|
mahmoud: 'محمود',
|
||||||
|
abdullah: 'عبدالله',
|
||||||
|
abdallah: 'عبدالله',
|
||||||
|
abdulrahman: 'عبدالرحمن',
|
||||||
|
omar: 'عمر',
|
||||||
|
ali: 'علي',
|
||||||
|
khaled: 'خالد',
|
||||||
|
khalil: 'خليل',
|
||||||
|
yazan: 'يزن',
|
||||||
|
yazeed: 'يزيد',
|
||||||
|
moaz: 'معاذ',
|
||||||
|
tareq: 'طارق',
|
||||||
|
tariq: 'طارق',
|
||||||
|
zaid: 'زيد',
|
||||||
|
zaidan: 'زيدان',
|
||||||
|
samer: 'سامر',
|
||||||
|
saif: 'سيف',
|
||||||
|
hossam: 'حسام',
|
||||||
|
bassem: 'باسم',
|
||||||
|
amjad: 'أمجد',
|
||||||
|
eyad: 'إياد',
|
||||||
|
hamza: 'حمزة',
|
||||||
|
haitham: 'هيثم',
|
||||||
|
wael: 'وائل',
|
||||||
|
raed: 'رائد',
|
||||||
|
fahad: 'فهد',
|
||||||
|
fares: 'فارس',
|
||||||
|
nasser: 'ناصر',
|
||||||
|
jameel: 'جميل',
|
||||||
|
}
|
||||||
|
|
||||||
|
const AR_TO_EN_CHAR = {
|
||||||
|
ا: 'a',
|
||||||
|
أ: 'a',
|
||||||
|
إ: 'i',
|
||||||
|
آ: 'aa',
|
||||||
|
ء: '',
|
||||||
|
ب: 'b',
|
||||||
|
ت: 't',
|
||||||
|
ث: 'th',
|
||||||
|
ج: 'j',
|
||||||
|
ح: 'h',
|
||||||
|
خ: 'kh',
|
||||||
|
د: 'd',
|
||||||
|
ذ: 'dh',
|
||||||
|
ر: 'r',
|
||||||
|
ز: 'z',
|
||||||
|
س: 's',
|
||||||
|
ش: 'sh',
|
||||||
|
ص: 's',
|
||||||
|
ض: 'd',
|
||||||
|
ط: 't',
|
||||||
|
ظ: 'z',
|
||||||
|
ع: 'a',
|
||||||
|
غ: 'gh',
|
||||||
|
ف: 'f',
|
||||||
|
ق: 'q',
|
||||||
|
ك: 'k',
|
||||||
|
ل: 'l',
|
||||||
|
م: 'm',
|
||||||
|
ن: 'n',
|
||||||
|
ه: 'h',
|
||||||
|
و: 'w',
|
||||||
|
ي: 'y',
|
||||||
|
ى: 'a',
|
||||||
|
ة: 'a',
|
||||||
|
ئ: 'e',
|
||||||
|
ؤ: 'o',
|
||||||
|
}
|
||||||
|
|
||||||
|
const EN_DIGRAPHS = [
|
||||||
|
['sh', 'ش'],
|
||||||
|
['kh', 'خ'],
|
||||||
|
['gh', 'غ'],
|
||||||
|
['th', 'ث'],
|
||||||
|
['dh', 'ذ'],
|
||||||
|
['ch', 'تش'],
|
||||||
|
['ph', 'ف'],
|
||||||
|
['aa', 'ا'],
|
||||||
|
['ee', 'ي'],
|
||||||
|
['oo', 'و'],
|
||||||
|
['ou', 'و'],
|
||||||
|
]
|
||||||
|
|
||||||
|
const EN_TO_AR_CHAR = {
|
||||||
|
a: 'ا',
|
||||||
|
b: 'ب',
|
||||||
|
c: 'ك',
|
||||||
|
d: 'د',
|
||||||
|
e: 'ي',
|
||||||
|
f: 'ف',
|
||||||
|
g: 'ج',
|
||||||
|
h: 'ه',
|
||||||
|
i: 'ي',
|
||||||
|
j: 'ج',
|
||||||
|
k: 'ك',
|
||||||
|
l: 'ل',
|
||||||
|
m: 'م',
|
||||||
|
n: 'ن',
|
||||||
|
o: 'و',
|
||||||
|
p: 'ب',
|
||||||
|
q: 'ق',
|
||||||
|
r: 'ر',
|
||||||
|
s: 'س',
|
||||||
|
t: 'ت',
|
||||||
|
u: 'و',
|
||||||
|
v: 'ف',
|
||||||
|
w: 'و',
|
||||||
|
x: 'كس',
|
||||||
|
y: 'ي',
|
||||||
|
z: 'ز',
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeArabicWord(word) {
|
||||||
|
return String(word || '')
|
||||||
|
.replace(ARABIC_DIACRITICS, '')
|
||||||
|
.replace(/[\u0622\u0623\u0625]/g, 'ا')
|
||||||
|
.replace(/ة/g, 'ه')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(word) {
|
||||||
|
if (!word) return ''
|
||||||
|
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function transliterateArabicWord(word) {
|
||||||
|
const normalized = normalizeArabicWord(word)
|
||||||
|
if (!normalized) return ''
|
||||||
|
if (AR_TO_EN_WORD[normalized]) return AR_TO_EN_WORD[normalized]
|
||||||
|
|
||||||
|
let out = ''
|
||||||
|
for (const ch of normalized) {
|
||||||
|
out += AR_TO_EN_CHAR[ch] ?? ch
|
||||||
|
}
|
||||||
|
return titleCase(out.replace(/aa+/g, 'a'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function transliterateEnglishWord(word) {
|
||||||
|
const normalized = String(word || '').trim().toLowerCase()
|
||||||
|
if (!normalized) return ''
|
||||||
|
if (EN_TO_AR_WORD[normalized]) return EN_TO_AR_WORD[normalized]
|
||||||
|
|
||||||
|
let left = normalized
|
||||||
|
let out = ''
|
||||||
|
while (left.length > 0) {
|
||||||
|
let matched = false
|
||||||
|
for (const [latin, arabic] of EN_DIGRAPHS) {
|
||||||
|
if (left.startsWith(latin)) {
|
||||||
|
out += arabic
|
||||||
|
left = left.slice(latin.length)
|
||||||
|
matched = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched) continue
|
||||||
|
out += EN_TO_AR_CHAR[left[0]] ?? left[0]
|
||||||
|
left = left.slice(1)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertNameAuto(direction, value) {
|
||||||
|
const words = String(value || '')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
if (words.length === 0) return ''
|
||||||
|
|
||||||
|
if (direction === 'ar_to_en') {
|
||||||
|
return words.map(transliterateArabicWord).filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
if (direction === 'en_to_ar') {
|
||||||
|
return words.map(transliterateEnglishWord).filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
return String(value || '')
|
||||||
|
}
|
||||||
|
|
||||||
83
frontend/src/utils/scoreGroupTheme.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { normalizedGroupCode } from './groups'
|
||||||
|
|
||||||
|
const PRESET = {
|
||||||
|
A: { accent: '#0d1931', soft: 'rgba(13, 25, 49, 0.14)', border: 'rgba(13, 25, 49, 0.44)' },
|
||||||
|
B: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' },
|
||||||
|
C: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' },
|
||||||
|
D: { accent: '#1b325f', soft: 'rgba(27, 50, 95, 0.14)', border: 'rgba(27, 50, 95, 0.42)' },
|
||||||
|
F1: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' },
|
||||||
|
F2: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' },
|
||||||
|
U: { accent: '#4c5f86', soft: 'rgba(76, 95, 134, 0.14)', border: 'rgba(76, 95, 134, 0.4)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEME_PALETTE = [
|
||||||
|
{ accent: '#0d1931', soft: 'rgba(13, 25, 49, 0.15)', border: 'rgba(13, 25, 49, 0.46)' },
|
||||||
|
{ accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.15)', border: 'rgba(0, 140, 168, 0.46)' },
|
||||||
|
{ accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.15)', border: 'rgba(237, 46, 35, 0.46)' },
|
||||||
|
{ accent: '#1b325f', soft: 'rgba(27, 50, 95, 0.15)', border: 'rgba(27, 50, 95, 0.44)' },
|
||||||
|
{ accent: '#0d5f72', soft: 'rgba(13, 95, 114, 0.15)', border: 'rgba(13, 95, 114, 0.44)' },
|
||||||
|
{ accent: '#b8261d', soft: 'rgba(184, 38, 29, 0.15)', border: 'rgba(184, 38, 29, 0.44)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function scoreGroupToken(row) {
|
||||||
|
const finalGroup = Number(row?.finalGroup || 0)
|
||||||
|
if (finalGroup === 1) return 'F1'
|
||||||
|
if (finalGroup === 2) return 'F2'
|
||||||
|
const code = normalizedGroupCode(row?.groupCode || '')
|
||||||
|
if (code) return code
|
||||||
|
return 'U'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreGroupStyle(row) {
|
||||||
|
const token = scoreGroupToken(row)
|
||||||
|
const preset = PRESET[token]
|
||||||
|
if (preset) {
|
||||||
|
return {
|
||||||
|
'--score-group-accent': preset.accent,
|
||||||
|
'--score-group-soft': preset.soft,
|
||||||
|
'--score-group-border': preset.border,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupThemeStyleForKey(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreValueStyle(scoreValue) {
|
||||||
|
const score = Number(scoreValue || 0)
|
||||||
|
const normalized = Number.isFinite(score) ? score : 0
|
||||||
|
const hue = hashToHue(`score:${normalized}`)
|
||||||
|
return {
|
||||||
|
'--score-group-accent': `hsl(${hue} 70% 38%)`,
|
||||||
|
'--score-group-soft': `hsla(${hue}, 72%, 46%, 0.10)`,
|
||||||
|
'--score-group-border': `hsla(${hue}, 70%, 38%, 0.34)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupThemeStyleForKey(key, options = {}) {
|
||||||
|
const raw = String(key || '').trim().toUpperCase()
|
||||||
|
const compact = raw.replace(/^[A-Z]/, '')
|
||||||
|
const numeric = Number.parseInt(compact || raw, 10)
|
||||||
|
const idx = Number.isFinite(numeric) && numeric > 0 ? (numeric - 1) % THEME_PALETTE.length : hashToHue(raw || 'U') % THEME_PALETTE.length
|
||||||
|
const base = THEME_PALETTE[idx]
|
||||||
|
if (!options.tieBreak) {
|
||||||
|
return {
|
||||||
|
'--score-group-accent': base.accent,
|
||||||
|
'--score-group-soft': base.soft,
|
||||||
|
'--score-group-border': base.border,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'--score-group-accent': base.accent,
|
||||||
|
'--score-group-soft': base.soft.replace('0.15', '0.2'),
|
||||||
|
'--score-group-border': base.border,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashToHue(value) {
|
||||||
|
const input = String(value || 'U')
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
|
hash = (hash * 31 + input.charCodeAt(i)) % 360
|
||||||
|
}
|
||||||
|
return (hash + 360) % 360
|
||||||
|
}
|
||||||
14
frontend/vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
137
getdata.json
Normal file
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
import_deps: [:ecto_sql],
|
||||||
|
inputs: ["*.exs"]
|
||||||
|
]
|
||||||