Compare commits
6 Commits
main
...
f6df640bd5
| Author | SHA1 | Date | |
|---|---|---|---|
| f6df640bd5 | |||
| 6f8195cda1 | |||
| 2ccfac2d04 | |||
| 99d28c2114 | |||
| 5863574a78 | |||
| 8cda54548f |
@@ -2,8 +2,6 @@
|
||||
.gitignore
|
||||
node_modules
|
||||
**/node_modules
|
||||
frontend/dist
|
||||
bin
|
||||
backend/data
|
||||
data
|
||||
*.db
|
||||
|
||||
182
API_CHANGES.md
Normal file
182
API_CHANGES.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# API Changes (Not Yet Reflected in `backend/openapi/openapi.json`)
|
||||
|
||||
This document lists additive API changes currently implemented in the Go backend.
|
||||
|
||||
## Summary
|
||||
|
||||
Existing endpoints remain compatible.
|
||||
|
||||
Added capabilities:
|
||||
|
||||
1. Position board persistence for live/admin ordering.
|
||||
2. Stage session control (`start`/`end`) for event flow.
|
||||
3. Stage history retrieval.
|
||||
4. New state fields (`positionBoards`, `current_stage`, `last_stage`).
|
||||
|
||||
## 1) Position Board Endpoint
|
||||
|
||||
- Method: `PUT`
|
||||
- Path: `/api/admin/positions/:board`
|
||||
- Auth: admin bearer token (`Authorization: Bearer <token>`)
|
||||
|
||||
### Supported `:board` values
|
||||
|
||||
- `preliminary`
|
||||
- `final`
|
||||
- `prelim_tiebreak`
|
||||
- `final_tiebreak`
|
||||
|
||||
### Request body
|
||||
|
||||
```json
|
||||
{
|
||||
"slots": [
|
||||
{ "playerId": 12, "groupKey": "A", "position": 1 },
|
||||
{ "playerId": 15, "groupKey": "A", "position": 2 },
|
||||
{ "playerId": 20, "groupKey": "B", "position": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `playerId` (integer, required)
|
||||
- `groupKey` (string, required)
|
||||
- `position` (integer > 0, required)
|
||||
|
||||
Notes:
|
||||
|
||||
- The request replaces all saved slots for the target board.
|
||||
- For `preliminary`, `players.group_code` is synchronized from `groupKey`.
|
||||
- Special value `UNASSIGNED` maps to empty player group.
|
||||
|
||||
### Response
|
||||
|
||||
- `200 OK` with full updated admin state payload.
|
||||
|
||||
## 2) Stage Session Control Endpoints
|
||||
|
||||
### Start stage
|
||||
|
||||
- Method: `POST`
|
||||
- Path: `/api/admin/stage/start`
|
||||
- Auth: admin bearer token
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"phase": "preliminary",
|
||||
"group": "A",
|
||||
"round": 2
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `phase` must be one of:
|
||||
- `preliminary`
|
||||
- `prelim_tiebreak`
|
||||
- `final`
|
||||
- `final_tiebreak`
|
||||
- `group` validation depends on phase:
|
||||
- `preliminary`: group code string (or `UNASSIGNED`)
|
||||
- `final`: `"1"` or `"2"`
|
||||
- tie-break phases: positive numeric string
|
||||
- `round` normalization:
|
||||
- `preliminary`: `1..3`
|
||||
- `final`: `1..2`
|
||||
- tie-break phases: always `1`
|
||||
- If another stage is active (started, not ended), API returns `409`.
|
||||
|
||||
Response:
|
||||
|
||||
- `200 OK` with full updated admin state payload.
|
||||
|
||||
### End stage
|
||||
|
||||
- Method: `POST`
|
||||
- Path: `/api/admin/stage/end`
|
||||
- Auth: admin bearer token
|
||||
|
||||
Rules:
|
||||
|
||||
- Ends the currently active stage by setting `ended_at`.
|
||||
- If no active stage exists, API returns `400`.
|
||||
|
||||
Response:
|
||||
|
||||
- `200 OK` with full updated admin state payload.
|
||||
|
||||
## 3) Stage History Endpoint
|
||||
|
||||
- Method: `GET`
|
||||
- Path: `/api/admin/stage/history`
|
||||
- Auth: admin bearer token
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"phase": "preliminary",
|
||||
"group": "A",
|
||||
"round": 2,
|
||||
"startedAt": "2026-04-30T08:27:13Z",
|
||||
"endedAt": "2026-04-30T08:28:01Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 4) New State Fields
|
||||
|
||||
Both endpoints now include these fields:
|
||||
|
||||
- `GET /api/state`
|
||||
- `GET /api/admin/state`
|
||||
|
||||
```json
|
||||
{
|
||||
"positionBoards": {
|
||||
"preliminary": {
|
||||
"12": { "groupKey": "A", "position": 1 }
|
||||
},
|
||||
"final": {
|
||||
"12": { "groupKey": "1", "position": 3 }
|
||||
},
|
||||
"prelim_tiebreak": {},
|
||||
"final_tiebreak": {}
|
||||
},
|
||||
"current_stage": {
|
||||
"id": 7,
|
||||
"phase": "preliminary",
|
||||
"group": "A",
|
||||
"round": 2,
|
||||
"startedAt": "2026-04-30T09:00:00Z"
|
||||
},
|
||||
"last_stage": {
|
||||
"id": 6,
|
||||
"phase": "final",
|
||||
"group": "1",
|
||||
"round": 1,
|
||||
"startedAt": "2026-04-30T08:45:00Z",
|
||||
"endedAt": "2026-04-30T08:55:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `positionBoards` keys are player IDs as strings.
|
||||
- `current_stage` is `null` when no stage is active.
|
||||
- `last_stage` is `null` when no stage session has ever started.
|
||||
- `last_stage` is the most recently started session (ended or still active).
|
||||
|
||||
## Compatibility Impact
|
||||
|
||||
- Additive only; existing consumers remain functional.
|
||||
- Consumers can ignore unknown fields safely.
|
||||
- Consumers needing control/monitoring can use new stage endpoints and fields.
|
||||
26
Dockerfile
26
Dockerfile
@@ -1,26 +1,18 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
COPY frontend/ ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.24 AS backend-builder
|
||||
WORKDIR /app/backend
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
COPY backend/ ./
|
||||
COPY --from=frontend-builder /app/frontend/dist ./web
|
||||
ARG TARGETARCH
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} go build -trimpath -ldflags="-s -w" -o /out/shooting-event .
|
||||
# FROM --platform=$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 --from=backend-builder /out/shooting-event /app/shooting-event
|
||||
COPY --from=backend-builder /app/backend/web /app/web
|
||||
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
|
||||
|
||||
44
Dockerfile.phoenix
Normal file
44
Dockerfile.phoenix
Normal file
@@ -0,0 +1,44 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
COPY frontend/ ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM elixir:1.18.4-otp-28 AS phoenix-builder
|
||||
ENV MIX_ENV=prod
|
||||
WORKDIR /app/phoenix
|
||||
|
||||
RUN mix local.hex --force && mix local.rebar --force
|
||||
|
||||
COPY phoenix/mix.exs phoenix/mix.lock ./
|
||||
RUN mix deps.get --only prod
|
||||
RUN mix deps.compile
|
||||
|
||||
COPY phoenix/config ./config
|
||||
COPY phoenix/lib ./lib
|
||||
COPY phoenix/priv ./priv
|
||||
RUN mix compile
|
||||
RUN mix release
|
||||
|
||||
FROM --platform=$TARGETPLATFORM elixir:1.18.4-otp-28
|
||||
RUN groupadd -r app && useradd -r -g app app
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=phoenix-builder /app/phoenix/_build/prod/rel/shooting_event_phx /app
|
||||
COPY --from=frontend-builder /app/frontend/dist /app/web
|
||||
|
||||
RUN mkdir -p /app/data && chown -R app:app /app
|
||||
USER app
|
||||
|
||||
ENV PHX_SERVER=true
|
||||
ENV PORT=8080
|
||||
ENV DB_PATH=/app/data/shooting.db
|
||||
ENV WEB_DIR=/app/web
|
||||
ENV SECRET_KEY_BASE=pxh_v1_V2Y3TXF4Rk9OUm5FdlVQc2s3M2hqd0t1Q2pwYUx4N3Vjb2I5Wk1qXzJ5RWRmQnJ
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/bin/shooting_event_phx"]
|
||||
CMD ["start"]
|
||||
28
Makefile
28
Makefile
@@ -3,16 +3,18 @@ SHELL := /bin/bash
|
||||
PNPM ?= pnpm
|
||||
GO ?= go
|
||||
CONTAINER_REG ?= repo.ssp-itinfra.com/admin
|
||||
IMAGE_NAME ?= shooting-event
|
||||
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 docker-build docker-run clean
|
||||
.PHONY: install dev dev-backend dev-frontend build build-frontend build-backend phoenix-dev phoenix-build docker-build docker-run docker-build-phoenix docker-run-phoenix clean
|
||||
|
||||
install:
|
||||
cd frontend && $(PNPM) install
|
||||
cd backend && $(GO) mod tidy
|
||||
cd phoenix && mix deps.get
|
||||
|
||||
dev-backend:
|
||||
cd backend && $(GO) run .
|
||||
@@ -34,12 +36,21 @@ 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
|
||||
@@ -52,7 +63,20 @@ 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:
|
||||
|
||||
40
README.md
40
README.md
@@ -34,29 +34,39 @@ Production-ready full-stack web app based on your original live score concept.
|
||||
8. Podium is determined automatically.
|
||||
9. If top-3 tie exists, podium tie-break stage appears.
|
||||
|
||||
## API Highlights
|
||||
## API Documentation (OpenAPI)
|
||||
|
||||
Public:
|
||||
Interactive docs are now built in:
|
||||
|
||||
- `GET /api/health`
|
||||
- `GET /api/state`
|
||||
- Swagger UI: `GET /api/docs`
|
||||
- OpenAPI JSON: `GET /api/openapi.json`
|
||||
|
||||
Admin:
|
||||
### Testing with Auth in Swagger UI
|
||||
|
||||
- `POST /api/admin/login`
|
||||
- `POST /api/admin/logout`
|
||||
- `POST /api/admin/players`
|
||||
- `PUT /api/admin/players/:id`
|
||||
- `DELETE /api/admin/players/:id`
|
||||
- `PUT /api/admin/scores/:stage/:id`
|
||||
- `POST /api/admin/scores/:stage/:id/advice`
|
||||
- `POST /api/admin/scores/:stage/reset`
|
||||
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.
|
||||
|
||||
Stages:
|
||||
### Stage Values
|
||||
|
||||
For score update/proof/advice endpoints (`/api/admin/scores/{stage}/...`), use:
|
||||
|
||||
- `prelim_r1`
|
||||
- `prelim_r2`
|
||||
- `prelim_r3`
|
||||
- `final_r1`
|
||||
- `final_r2`
|
||||
- `prelim_tiebreak`
|
||||
- `final_tiebreak`
|
||||
|
||||
For reset endpoint (`POST /api/admin/scores/{stage}/reset`), use:
|
||||
|
||||
- `preliminary`
|
||||
- `prelim_tiebreak`
|
||||
- `final`
|
||||
- `prelim_tiebreak`
|
||||
- `final_tiebreak`
|
||||
|
||||
## Local Development
|
||||
|
||||
@@ -69,7 +69,7 @@ func (a *App) loadCurrentScore(stage string, playerID int) (int, error) {
|
||||
}
|
||||
|
||||
func (a *App) buildAdviceResponse(stage string, playerID int, raw scoreAdviceModelResponse) ScoreAdviceResponse {
|
||||
advised := clampInt(raw.AdvisedScore, 0, 9999)
|
||||
advised := clampInt(raw.AdvisedScore, 0, 100)
|
||||
reason := strings.TrimSpace(raw.Reason)
|
||||
if reason == "" {
|
||||
reason = "AI estimated the score from visible impacts."
|
||||
|
||||
145
backend/api_docs.go
Normal file
145
backend/api_docs.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
//go:embed openapi/openapi.json
|
||||
var openAPISpecJSON string
|
||||
|
||||
func (a *App) handleOpenAPISpec(c echo.Context) error {
|
||||
return c.Blob(http.StatusOK, "application/json; charset=utf-8", []byte(openAPISpecJSON))
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIDocs(c echo.Context) error {
|
||||
html := fmt.Sprintf(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Shooting Event API Docs</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
.docs-top {
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #dde5f3;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.docs-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
color: #1b1f40;
|
||||
}
|
||||
.docs-help {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #4c5773;
|
||||
}
|
||||
.auth-panel {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.auth-panel input {
|
||||
height: 36px;
|
||||
border: 1px solid #cdd7eb;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.auth-panel button {
|
||||
height: 36px;
|
||||
border: 1px solid #20284f;
|
||||
background: #20284f;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auth-status {
|
||||
font-size: 13px;
|
||||
color: #38446b;
|
||||
}
|
||||
#swagger-ui {
|
||||
max-width: 1300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="docs-top">
|
||||
<h1 class="docs-title">Shooting Event OpenAPI Docs</h1>
|
||||
<p class="docs-help">
|
||||
Use <strong>Try it out</strong> for live testing. For admin endpoints: login with the form below,
|
||||
then your token is auto-attached to Swagger Authorize.
|
||||
</p>
|
||||
|
||||
<form id="auth-form" class="auth-panel">
|
||||
<input id="admin-user" type="text" value="datwyler" autocomplete="username" placeholder="Admin username" />
|
||||
<input id="admin-pass" type="password" value="datwyler" autocomplete="current-password" placeholder="Admin password" />
|
||||
<button type="submit">Login & Authorize</button>
|
||||
<span id="auth-status" class="auth-status"></span>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
const ui = SwaggerUIBundle({
|
||||
url: %q,
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
displayRequestDuration: true,
|
||||
persistAuthorization: true,
|
||||
docExpansion: 'list',
|
||||
tryItOutEnabled: true,
|
||||
});
|
||||
|
||||
async function loginAndAuthorize(event) {
|
||||
event.preventDefault();
|
||||
const status = document.getElementById('auth-status');
|
||||
status.textContent = 'Logging in...';
|
||||
|
||||
const username = document.getElementById('admin-user').value || '';
|
||||
const password = document.getElementById('admin-pass').value || '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || 'Login failed');
|
||||
}
|
||||
if (!payload.token) {
|
||||
throw new Error('Login succeeded but token was not returned');
|
||||
}
|
||||
|
||||
ui.preauthorizeApiKey('bearerAuth', payload.token);
|
||||
status.textContent = 'Authorized until ' + (payload.expiresAt || 'token expiry');
|
||||
} catch (error) {
|
||||
status.textContent = 'Auth failed: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('auth-form').addEventListener('submit', loginAndAuthorize);
|
||||
</script>
|
||||
</body>
|
||||
</html>`, "/api/openapi.json")
|
||||
|
||||
return c.HTML(http.StatusOK, html)
|
||||
}
|
||||
@@ -59,12 +59,31 @@ CREATE TABLE IF NOT EXISTS score_attachments (
|
||||
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
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'),
|
||||
|
||||
@@ -198,6 +198,6 @@ func scoreAdvicePrompt(stage string, currentScore int) string {
|
||||
return fmt.Sprintf(`Target scoring assistant.
|
||||
Stage: %s. Current score: %d.
|
||||
Return STRICT JSON only:
|
||||
{"advisedScore":<int 0..9999>,"reason":"<max 18 words>"}
|
||||
{"advisedScore":<int 0..100>,"reason":"<max 18 words>"}
|
||||
Do not add markdown or extra fields.`, stage, currentScore)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,84 @@ func (a *App) handleUpdateAdminSettings(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -295,6 +373,77 @@ func (a *App) handleAutoGroupPlayers(c echo.Context) 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 {
|
||||
@@ -310,8 +459,8 @@ func (a *App) handleUpdateScore(c echo.Context) error {
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
if req.Score < 0 || req.Score > 9999 {
|
||||
return writeError(c, http.StatusBadRequest, "score must be between 0 and 9999")
|
||||
if req.Score < 0 || req.Score > 100 {
|
||||
return writeError(c, http.StatusBadRequest, "score must be between 0 and 100")
|
||||
}
|
||||
|
||||
res, err := a.db.Exec(`
|
||||
@@ -382,7 +531,9 @@ func resolveResetStages(stage string) ([]string, error) {
|
||||
case "preliminary":
|
||||
return append([]string{}, preliminaryRoundStages...), nil
|
||||
case "final":
|
||||
return append([]string{}, finalRoundStages...), nil
|
||||
stages := append([]string{}, finalRoundStages...)
|
||||
stages = append(stages, "final_tiebreak")
|
||||
return stages, nil
|
||||
case "prelim_tiebreak", "final_tiebreak":
|
||||
normalized, err := validateStage(stage)
|
||||
if err != nil {
|
||||
|
||||
@@ -88,11 +88,28 @@ type StateResponse struct {
|
||||
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"`
|
||||
@@ -170,6 +187,16 @@ 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"`
|
||||
@@ -177,3 +204,9 @@ type ScoreAdviceResponse struct {
|
||||
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
1410
backend/openapi/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,10 @@ import (
|
||||
|
||||
func registerAPIRoutes(e *echo.Echo, app *App) {
|
||||
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)
|
||||
@@ -18,9 +22,13 @@ func registerAPIRoutes(e *echo.Echo, app *App) {
|
||||
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)
|
||||
|
||||
@@ -355,7 +355,7 @@ func normalizeRotationPlayerCountInt(value int) int {
|
||||
|
||||
func normalizeLiveActiveView(value string, fallback string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||
case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "podium":
|
||||
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)) != "" {
|
||||
|
||||
172
backend/stage_sessions.go
Normal file
172
backend/stage_sessions.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
stagePhasePreliminary = "preliminary"
|
||||
stagePhasePrelimTie = "prelim_tiebreak"
|
||||
stagePhaseFinal = "final"
|
||||
stagePhaseFinalTie = "final_tiebreak"
|
||||
)
|
||||
|
||||
var allowedStagePhases = map[string]bool{
|
||||
stagePhasePreliminary: true,
|
||||
stagePhasePrelimTie: true,
|
||||
stagePhaseFinal: true,
|
||||
stagePhaseFinalTie: true,
|
||||
}
|
||||
|
||||
func normalizeStagePhase(raw string) (string, bool) {
|
||||
phase := strings.ToLower(strings.TrimSpace(raw))
|
||||
if !allowedStagePhases[phase] {
|
||||
return "", false
|
||||
}
|
||||
return phase, true
|
||||
}
|
||||
|
||||
func normalizeStageGroup(phase string, raw string) (string, bool) {
|
||||
group := strings.TrimSpace(raw)
|
||||
switch phase {
|
||||
case stagePhasePreliminary:
|
||||
if strings.EqualFold(group, positionBoardUnassigned) {
|
||||
return positionBoardUnassigned, true
|
||||
}
|
||||
group = normalizePreliminaryGroupKey(group)
|
||||
if group == "" {
|
||||
return "", false
|
||||
}
|
||||
return group, true
|
||||
case stagePhaseFinal:
|
||||
parsed, err := strconv.Atoi(group)
|
||||
if err != nil || (parsed != 1 && parsed != 2) {
|
||||
return "", false
|
||||
}
|
||||
return strconv.Itoa(parsed), true
|
||||
case stagePhasePrelimTie, stagePhaseFinalTie:
|
||||
parsed, err := strconv.Atoi(group)
|
||||
if err != nil || parsed <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.Itoa(parsed), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStageRound(phase string, round int) int {
|
||||
switch phase {
|
||||
case stagePhasePreliminary:
|
||||
if round < 1 {
|
||||
return 1
|
||||
}
|
||||
if round > 3 {
|
||||
return 3
|
||||
}
|
||||
return round
|
||||
case stagePhaseFinal:
|
||||
if round < 1 {
|
||||
return 1
|
||||
}
|
||||
if round > 2 {
|
||||
return 2
|
||||
}
|
||||
return round
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTimestamp(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
|
||||
return parsed.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil {
|
||||
return parsed.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func scanStageSession(scanner interface{ Scan(dest ...any) error }) (*StageSessionState, error) {
|
||||
var item StageSessionState
|
||||
var ended sql.NullString
|
||||
if err := scanner.Scan(&item.ID, &item.Phase, &item.GroupKey, &item.Round, &item.StartedAt, &ended); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Phase = strings.ToLower(strings.TrimSpace(item.Phase))
|
||||
item.GroupKey = strings.TrimSpace(item.GroupKey)
|
||||
item.StartedAt = normalizeTimestamp(item.StartedAt)
|
||||
if ended.Valid {
|
||||
item.EndedAt = normalizeTimestamp(ended.String)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (a *App) readCurrentStage() (*StageSessionState, error) {
|
||||
row := a.db.QueryRow(`
|
||||
SELECT id, phase, group_key, round, started_at, ended_at
|
||||
FROM stage_sessions
|
||||
WHERE ended_at IS NULL
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`)
|
||||
item, err := scanStageSession(row)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read current stage: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (a *App) readLastStage() (*StageSessionState, error) {
|
||||
row := a.db.QueryRow(`
|
||||
SELECT id, phase, group_key, round, started_at, ended_at
|
||||
FROM stage_sessions
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`)
|
||||
item, err := scanStageSession(row)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read last stage: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (a *App) readStageHistory() ([]StageSessionState, error) {
|
||||
rows, err := a.db.Query(`
|
||||
SELECT id, phase, group_key, round, started_at, ended_at
|
||||
FROM stage_sessions
|
||||
ORDER BY started_at DESC, id DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query stage history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]StageSessionState, 0)
|
||||
for rows.Next() {
|
||||
item, scanErr := scanStageSession(rows)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("scan stage history: %w", scanErr)
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate stage history: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
234
backend/state.go
234
backend/state.go
@@ -8,6 +8,21 @@ import (
|
||||
"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 {
|
||||
@@ -38,6 +53,18 @@ func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -46,8 +73,11 @@ func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
|
||||
},
|
||||
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 {
|
||||
@@ -133,6 +163,210 @@ func scoreProofMapToJSON(proofMap map[string]map[int]string) map[string]map[stri
|
||||
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
|
||||
|
||||
BIN
frontend/public/arture.png
Normal file
BIN
frontend/public/arture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
frontend/public/datwyler_logo.png
Normal file
BIN
frontend/public/datwyler_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/public/logo_white.png
Normal file
BIN
frontend/public/logo_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
BIN
frontend/public/simon_logo.png
Normal file
BIN
frontend/public/simon_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="app-shell" :class="'lang-' + language">
|
||||
<div class="app-shell" :class="['lang-' + language, 'mode-' + mode]">
|
||||
<input ref="imageInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onImageSelected" />
|
||||
<input ref="scoreProofInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onScoreProofSelected" />
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
:competition-title="competitionTitle"
|
||||
:mode="mode"
|
||||
:language="language"
|
||||
:is-fullscreen="isFullscreen"
|
||||
:server-time="state.serverTime ? formatServerTime(state.serverTime) : ''"
|
||||
@change-mode="mode = $event"
|
||||
@change-language="setLanguage"
|
||||
@toggle-fullscreen="toggleFullscreen"
|
||||
/>
|
||||
|
||||
<main class="page-content">
|
||||
@@ -69,18 +71,20 @@
|
||||
v-else-if="mode === 'live'"
|
||||
:t="t"
|
||||
:live-settings="liveSettings"
|
||||
:position-boards="positionBoards"
|
||||
:live-group-code="liveMonitorGroupCode"
|
||||
:live-group-members="liveMonitorMembers"
|
||||
:prelim-tie="prelimTie"
|
||||
:prelim-tie-rows="prelimTieRows"
|
||||
:preliminary-rows="preliminaryRows"
|
||||
:finalists="finalists"
|
||||
:live-final-group="liveMonitorFinalGroup"
|
||||
:live-final-rows="liveMonitorFinalRows"
|
||||
:final-overall-rows="finalRows"
|
||||
:final-tie-rows="finalTieRows"
|
||||
:podium-ordered="podiumOrdered"
|
||||
:player-image="playerImage"
|
||||
:display-name="displayName"
|
||||
:secondary-name="secondaryName"
|
||||
:score-for="scoreFor"
|
||||
:prelim-total="preliminaryTotalScore"
|
||||
:final-total="finalTotalScore"
|
||||
@@ -110,6 +114,12 @@
|
||||
:admin-tab="adminTab"
|
||||
:view-proof-in-view="canViewProofs"
|
||||
:live-settings="liveSettings"
|
||||
:stage-control="stageControl"
|
||||
:stage-options="stageOptions"
|
||||
:current-stage="currentStage"
|
||||
:last-stage="lastStage"
|
||||
:active-scoring-stage="currentScoringStageKey"
|
||||
:active-scoring-group="currentScoringGroupKey"
|
||||
:group-setup-input="groupSetupInput"
|
||||
:admin-group-cards="adminGroupCards"
|
||||
:new-player="newPlayer"
|
||||
@@ -129,10 +139,12 @@
|
||||
:final-total-score="finalTotalScore"
|
||||
:prelim-tie="prelimTie"
|
||||
:final-tie="finalTie"
|
||||
:position-boards="positionBoards"
|
||||
:score-filters="scoreFilters"
|
||||
: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"
|
||||
@@ -146,6 +158,10 @@
|
||||
@logout="adminLogout"
|
||||
@toggle-view-proof="updateViewProofSetting"
|
||||
@update-live-setting="updateLiveModeSetting"
|
||||
@update-stage-control="updateStageControl"
|
||||
@start-stage="startCurrentStage"
|
||||
@end-stage="endCurrentStage"
|
||||
@navigate-current-stage="navigateToCurrentStage"
|
||||
@change-admin-tab="adminTab = $event"
|
||||
@update:group-setup-input="groupSetupInput = $event"
|
||||
@save-group-setup="saveGroupSetup"
|
||||
@@ -162,6 +178,8 @@
|
||||
@remove-player-image="removePlayerImage"
|
||||
@delete-player="deletePlayer"
|
||||
@request-reset-stage="openResetStageModal"
|
||||
@reset-final-group-by-seed="resetFinalGroupBySeed"
|
||||
@save-position-board="savePositionBoard"
|
||||
@update-score-filter="updateScoreFilter"
|
||||
/>
|
||||
</template>
|
||||
@@ -216,12 +234,14 @@ const FALLBACK_SYNC_MS = 15000
|
||||
const STREAM_RETRY_MS = 1500
|
||||
const PRELIM_ROUND_STAGES = ['prelim_r1', 'prelim_r2', 'prelim_r3']
|
||||
const FINAL_ROUND_STAGES = ['final_r1', 'final_r2']
|
||||
const POSITION_BOARDS = ['preliminary', 'final', 'prelim_tiebreak', 'final_tiebreak']
|
||||
|
||||
const loading = ref(true)
|
||||
const mode = ref('live')
|
||||
const viewTab = ref('groups')
|
||||
const adminTab = ref('players')
|
||||
const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar')
|
||||
const isFullscreen = ref(false)
|
||||
const state = ref(createInitialState())
|
||||
const primaryGroups = ref(loadPrimaryGroups(localStorage))
|
||||
|
||||
@@ -243,6 +263,11 @@ const scoreFilters = reactive({
|
||||
prelim_tiebreak: '',
|
||||
final_tiebreak: '',
|
||||
})
|
||||
const stageControl = reactive({
|
||||
phase: 'preliminary',
|
||||
group: '',
|
||||
round: 1,
|
||||
})
|
||||
|
||||
const liveGroupIndex = ref(0)
|
||||
const liveMode = ref('rotate')
|
||||
@@ -294,6 +319,14 @@ const finalRows = computed(() => state.value.derived?.finalRanking?.rows || [])
|
||||
const podiumRows = computed(() => state.value.derived?.podium || [])
|
||||
const prelimTie = computed(() => state.value.derived?.preliminaryRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] })
|
||||
const finalTie = computed(() => state.value.derived?.finalRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] })
|
||||
const positionBoards = computed(() => {
|
||||
const raw = state.value.positionBoards || {}
|
||||
const out = {}
|
||||
for (const board of POSITION_BOARDS) {
|
||||
out[board] = raw[board] || {}
|
||||
}
|
||||
return out
|
||||
})
|
||||
const canViewProofs = computed(() => Boolean(state.value.settings?.viewProofInView))
|
||||
const liveSettings = computed(() => {
|
||||
const raw = state.value.settings?.liveMode || {}
|
||||
@@ -316,6 +349,10 @@ const liveSettings = computed(() => {
|
||||
rotationPlayerCount: Number(raw.rotationPlayerCount) > 0 ? Number(raw.rotationPlayerCount) : 12,
|
||||
}
|
||||
})
|
||||
const currentStage = computed(() => state.value.current_stage || null)
|
||||
const lastStage = computed(() => state.value.last_stage || null)
|
||||
const currentScoringStageKey = computed(() => mapStageSessionToScoreStage(currentStage.value))
|
||||
const currentScoringGroupKey = computed(() => mapStageSessionToScoreGroup(currentStage.value))
|
||||
|
||||
const viewTabs = computed(() => [
|
||||
{ id: 'groups', label: t('tabs.groups') },
|
||||
@@ -405,7 +442,8 @@ const liveGroupCode = computed(() => {
|
||||
const liveMembers = computed(() => {
|
||||
const code = normalizedGroupCode(liveGroupCode.value)
|
||||
if (!code) return []
|
||||
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
||||
const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
||||
return sortRowsByPreliminaryPosition(rows)
|
||||
})
|
||||
|
||||
const liveMonitorGroupRotationCodes = computed(() => activeGroupCodes.value)
|
||||
@@ -420,7 +458,8 @@ const liveMonitorGroupCode = computed(() => {
|
||||
const liveMonitorMembers = computed(() => {
|
||||
const code = normalizedGroupCode(liveMonitorGroupCode.value)
|
||||
if (!code) return []
|
||||
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
||||
const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
||||
return sortRowsByPreliminaryPosition(rows)
|
||||
})
|
||||
const liveMonitorFinalGroup = computed(() => {
|
||||
if (liveSettings.value.finalDisplayMode === 'fixed') {
|
||||
@@ -428,17 +467,32 @@ const liveMonitorFinalGroup = computed(() => {
|
||||
}
|
||||
return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2
|
||||
})
|
||||
const liveMonitorFinalRows = computed(() => (liveMonitorFinalGroup.value === 2 ? finalGroup2.value : finalGroup1.value))
|
||||
const finalBoardGroupedRows = computed(() => {
|
||||
const sorted = sortRowsByBoardPosition('final', adminFinalRows.value)
|
||||
const grouped = {}
|
||||
for (const row of sorted) {
|
||||
const key = positionSlotFor('final', row.playerId)?.groupKey || String(row.finalGroup || 1)
|
||||
if (!grouped[key]) grouped[key] = []
|
||||
grouped[key].push(row)
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
const liveMonitorFinalRows = computed(() => {
|
||||
const key = String(liveMonitorFinalGroup.value === 2 ? 2 : 1)
|
||||
return finalBoardGroupedRows.value[key] || []
|
||||
})
|
||||
const podiumOrdered = computed(() => [podiumRows.value[1] || null, podiumRows.value[0] || null, podiumRows.value[2] || null])
|
||||
|
||||
const prelimTieRows = computed(() => {
|
||||
const ids = new Set(prelimTie.value.playerIds || [])
|
||||
return preliminaryRows.value.filter((row) => ids.has(row.playerId))
|
||||
const rows = preliminaryRows.value.filter((row) => ids.has(row.playerId))
|
||||
return sortRowsByBoardPosition('prelim_tiebreak', rows)
|
||||
})
|
||||
|
||||
const finalTieRows = computed(() => {
|
||||
const ids = new Set(finalTie.value.playerIds || [])
|
||||
return finalRows.value.filter((row) => ids.has(row.playerId))
|
||||
const rows = finalRows.value.filter((row) => ids.has(row.playerId))
|
||||
return sortRowsByBoardPosition('final_tiebreak', rows)
|
||||
})
|
||||
|
||||
const playerByID = computed(() => {
|
||||
@@ -479,8 +533,7 @@ const adminPreliminaryRows = computed(() => {
|
||||
rank: derived?.rank ?? 0,
|
||||
}
|
||||
})
|
||||
rows.sort(compareRowsByGroupThenId)
|
||||
return rows
|
||||
return sortRowsByBoardPosition('preliminary', rows)
|
||||
})
|
||||
|
||||
const adminPrelimTieRows = computed(() => {
|
||||
@@ -500,8 +553,7 @@ const adminPrelimTieRows = computed(() => {
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
rows.sort(compareRowsByScoreGroupThenId)
|
||||
return rows
|
||||
return sortRowsByBoardPosition('prelim_tiebreak', rows)
|
||||
})
|
||||
|
||||
const adminFinalRows = computed(() => {
|
||||
@@ -521,17 +573,7 @@ const adminFinalRows = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const groupA = Number(a.finalGroup || 0)
|
||||
const groupB = Number(b.finalGroup || 0)
|
||||
if (groupA !== groupB) return groupA - groupB
|
||||
const seedA = Number(a.seed || 0)
|
||||
const seedB = Number(b.seed || 0)
|
||||
if (seedA !== seedB) return seedA - seedB
|
||||
return a.playerId - b.playerId
|
||||
})
|
||||
|
||||
return rows
|
||||
return sortRowsByBoardPosition('final', rows)
|
||||
})
|
||||
|
||||
const adminFinalTieRows = computed(() => {
|
||||
@@ -554,8 +596,29 @@ const adminFinalTieRows = computed(() => {
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
rows.sort(compareRowsByScoreGroupThenId)
|
||||
return rows
|
||||
return sortRowsByBoardPosition('final_tiebreak', rows)
|
||||
})
|
||||
|
||||
const stageOptions = computed(() => {
|
||||
const preliminaryGroups = collectUniqueStrings([
|
||||
...assignableGroups.value,
|
||||
...activeGroupCodes.value,
|
||||
])
|
||||
const finalGroups = collectUniqueStrings([
|
||||
...Object.keys(finalBoardGroupedRows.value || {}),
|
||||
'1',
|
||||
'2',
|
||||
]).filter((value) => value === '1' || value === '2')
|
||||
|
||||
const prelimTieGroups = collectUniqueStrings((adminPrelimTieRows.value || []).map((row) => String(row._boardGroupKey || row.scoreGroup || '1')))
|
||||
const finalTieGroups = collectUniqueStrings((adminFinalTieRows.value || []).map((row) => String(row._boardGroupKey || row.scoreGroup || '1')))
|
||||
|
||||
return {
|
||||
preliminary: preliminaryGroups,
|
||||
prelim_tiebreak: prelimTieGroups.length > 0 ? prelimTieGroups : ['1'],
|
||||
final: finalGroups.length > 0 ? finalGroups : ['1', '2'],
|
||||
final_tiebreak: finalTieGroups.length > 0 ? finalTieGroups : ['1'],
|
||||
}
|
||||
})
|
||||
|
||||
watch(language, (value) => {
|
||||
@@ -639,7 +702,20 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => `${stageControl.phase}|${JSON.stringify(stageOptions.value)}`,
|
||||
() => {
|
||||
applyStageControlDefaults()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
onFullscreenChange()
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
document.addEventListener('webkitfullscreenchange', onFullscreenChange)
|
||||
document.addEventListener('mozfullscreenchange', onFullscreenChange)
|
||||
document.addEventListener('MSFullscreenChange', onFullscreenChange)
|
||||
document.documentElement.lang = language.value
|
||||
document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr'
|
||||
await fetchState()
|
||||
@@ -647,6 +723,10 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
document.removeEventListener('webkitfullscreenchange', onFullscreenChange)
|
||||
document.removeEventListener('mozfullscreenchange', onFullscreenChange)
|
||||
document.removeEventListener('MSFullscreenChange', onFullscreenChange)
|
||||
stopLiveRotation()
|
||||
stopLiveMonitorRotation()
|
||||
stopRealtimeSync()
|
||||
@@ -662,9 +742,60 @@ function setLanguage(next) {
|
||||
if (next === 'ar' || next === 'en') language.value = next
|
||||
}
|
||||
|
||||
function getFullscreenElement() {
|
||||
return (
|
||||
document.fullscreenElement ||
|
||||
document.webkitFullscreenElement ||
|
||||
document.mozFullScreenElement ||
|
||||
document.msFullscreenElement ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
isFullscreen.value = Boolean(getFullscreenElement())
|
||||
}
|
||||
|
||||
function requestFullscreenFor(element) {
|
||||
if (!element) return Promise.resolve()
|
||||
const fn =
|
||||
element.requestFullscreen ||
|
||||
element.webkitRequestFullscreen ||
|
||||
element.mozRequestFullScreen ||
|
||||
element.msRequestFullscreen
|
||||
if (!fn) return Promise.resolve()
|
||||
const result = fn.call(element)
|
||||
if (result && typeof result.then === 'function') return result
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
function exitFullscreen() {
|
||||
const fn =
|
||||
document.exitFullscreen ||
|
||||
document.webkitExitFullscreen ||
|
||||
document.mozCancelFullScreen ||
|
||||
document.msExitFullscreen
|
||||
if (!fn) return Promise.resolve()
|
||||
const result = fn.call(document)
|
||||
if (result && typeof result.then === 'function') return result
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
try {
|
||||
if (getFullscreenElement()) {
|
||||
await exitFullscreen()
|
||||
return
|
||||
}
|
||||
await requestFullscreenFor(document.documentElement)
|
||||
} catch {
|
||||
// Ignore fullscreen errors, mostly caused by browser policy.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLiveActiveView(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'podium'])
|
||||
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'final_overall', 'podium'])
|
||||
return allowed.has(normalized) ? normalized : 'group_live'
|
||||
}
|
||||
|
||||
@@ -724,6 +855,116 @@ function compareRowsByScoreGroupThenId(a, b) {
|
||||
return compareRowsByGroupThenId(a, b)
|
||||
}
|
||||
|
||||
function positionSlotFor(board, playerId) {
|
||||
const boardMap = positionBoards.value?.[board] || {}
|
||||
return boardMap[String(playerId)] || boardMap[playerId] || null
|
||||
}
|
||||
|
||||
function normalizePositionGroup(board, raw, fallback = '') {
|
||||
if (board === 'preliminary') {
|
||||
const normalized = normalizedGroupCode(raw || '')
|
||||
if (normalized) return normalized
|
||||
if (String(raw || '').trim().toUpperCase() === 'UNASSIGNED') return 'UNASSIGNED'
|
||||
return fallback || 'UNASSIGNED'
|
||||
}
|
||||
const parsed = Number.parseInt(String(raw || ''), 10)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return String(parsed)
|
||||
return fallback || '1'
|
||||
}
|
||||
|
||||
function normalizePositionValue(raw, fallback = 1) {
|
||||
const parsed = Number(raw)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed)
|
||||
return fallback
|
||||
}
|
||||
|
||||
function positionGroupSortValue(board, groupKey) {
|
||||
if (board === 'preliminary') {
|
||||
if (groupKey === 'UNASSIGNED') return Number.MAX_SAFE_INTEGER - 1
|
||||
const idx = assignableGroups.value.indexOf(groupKey)
|
||||
if (idx >= 0) return idx
|
||||
return Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
return Number.parseInt(String(groupKey || '0'), 10) || Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
function sortRowsByPreliminaryPosition(rows) {
|
||||
return sortRowsByBoardPosition('preliminary', rows)
|
||||
}
|
||||
|
||||
function sortRowsByBoardPosition(board, rows) {
|
||||
const list = Array.isArray(rows) ? rows.slice() : []
|
||||
if (list.length <= 1) return list
|
||||
|
||||
const fallbackRows = list.slice()
|
||||
if (board === 'preliminary') {
|
||||
fallbackRows.sort(compareRowsByGroupThenId)
|
||||
} else if (board === 'final') {
|
||||
fallbackRows.sort((a, b) => {
|
||||
const groupA = Number(a.finalGroup || 0)
|
||||
const groupB = Number(b.finalGroup || 0)
|
||||
if (groupA !== groupB) return groupA - groupB
|
||||
const seedA = Number(a.seed || 0)
|
||||
const seedB = Number(b.seed || 0)
|
||||
if (seedA !== seedB) return seedA - seedB
|
||||
return Number(a.playerId || a.id || 0) - Number(b.playerId || b.id || 0)
|
||||
})
|
||||
} else {
|
||||
fallbackRows.sort(compareRowsByScoreGroupThenId)
|
||||
}
|
||||
|
||||
const fallbackMap = new Map()
|
||||
const fallbackCountByGroup = new Map()
|
||||
for (let index = 0; index < fallbackRows.length; index += 1) {
|
||||
const row = fallbackRows[index]
|
||||
const playerId = Number(row.playerId || row.id || 0)
|
||||
let fallbackGroup = '1'
|
||||
if (board === 'preliminary') {
|
||||
fallbackGroup = normalizePositionGroup(board, row.groupCode, 'UNASSIGNED')
|
||||
} else if (board === 'final') {
|
||||
fallbackGroup = normalizePositionGroup(board, row.finalGroup, '1')
|
||||
} else if (board === 'prelim_tiebreak' || board === 'final_tiebreak') {
|
||||
const scoreGroup = Number.parseInt(String(row.scoreGroup || ''), 10)
|
||||
const finalGroup = Number.parseInt(String(row.finalGroup || ''), 10)
|
||||
if (Number.isFinite(scoreGroup) && scoreGroup > 0) {
|
||||
fallbackGroup = String(scoreGroup)
|
||||
} else if (Number.isFinite(finalGroup) && finalGroup > 0) {
|
||||
fallbackGroup = String(finalGroup)
|
||||
} else {
|
||||
fallbackGroup = '1'
|
||||
}
|
||||
} else {
|
||||
fallbackGroup = '1'
|
||||
}
|
||||
const nextPos = (fallbackCountByGroup.get(fallbackGroup) || 0) + 1
|
||||
fallbackCountByGroup.set(fallbackGroup, nextPos)
|
||||
fallbackMap.set(playerId, { groupKey: fallbackGroup, position: nextPos, index })
|
||||
}
|
||||
|
||||
return list
|
||||
.map((row) => {
|
||||
const playerId = Number(row.playerId || row.id || 0)
|
||||
const fallback = fallbackMap.get(playerId) || { groupKey: '1', position: Number.MAX_SAFE_INTEGER, index: Number.MAX_SAFE_INTEGER }
|
||||
const slot = positionSlotFor(board, playerId)
|
||||
const groupKey = normalizePositionGroup(board, slot?.groupKey, fallback.groupKey)
|
||||
const position = normalizePositionValue(slot?.position, fallback.position)
|
||||
return { row, groupKey, position, fallbackIndex: fallback.index }
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const groupOrderA = positionGroupSortValue(board, a.groupKey)
|
||||
const groupOrderB = positionGroupSortValue(board, b.groupKey)
|
||||
if (groupOrderA !== groupOrderB) return groupOrderA - groupOrderB
|
||||
if (a.groupKey !== b.groupKey) return String(a.groupKey).localeCompare(String(b.groupKey))
|
||||
if (a.position !== b.position) return a.position - b.position
|
||||
return a.fallbackIndex - b.fallbackIndex
|
||||
})
|
||||
.map((entry) => ({
|
||||
...entry.row,
|
||||
_boardGroupKey: entry.groupKey,
|
||||
_boardPosition: entry.position,
|
||||
}))
|
||||
}
|
||||
|
||||
function saveGroupSetup() {
|
||||
const parsed = parseGroupList(groupSetupInput.value)
|
||||
primaryGroups.value = parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS
|
||||
@@ -765,6 +1006,55 @@ function updateScoreFilter({ stage, value }) {
|
||||
scoreFilters[stage] = value
|
||||
}
|
||||
|
||||
async function savePositionBoard(payload) {
|
||||
const board = String(payload?.board || '').trim().toLowerCase()
|
||||
if (!POSITION_BOARDS.includes(board)) return
|
||||
const slots = Array.isArray(payload?.slots) ? payload.slots : []
|
||||
|
||||
try {
|
||||
state.value = await api(`/admin/positions/${board}`, {
|
||||
method: 'PUT',
|
||||
admin: true,
|
||||
body: { slots },
|
||||
})
|
||||
} catch (error) {
|
||||
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function resetFinalGroupBySeed() {
|
||||
const ranked = [...adminFinalRows.value]
|
||||
.filter((row) => Number(row.seed || 0) > 0)
|
||||
.sort((a, b) => {
|
||||
const seedA = Number(a.seed || 0)
|
||||
const seedB = Number(b.seed || 0)
|
||||
if (seedA !== seedB) return seedA - seedB
|
||||
return Number(a.playerId || 0) - Number(b.playerId || 0)
|
||||
})
|
||||
|
||||
if (ranked.length === 0) {
|
||||
showToast(t('labels.noFinalists'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const slots = ranked.map((row, index) => ({
|
||||
playerId: row.playerId,
|
||||
groupKey: index < 6 ? '1' : '2',
|
||||
position: (index % 6) + 1,
|
||||
}))
|
||||
|
||||
try {
|
||||
state.value = await api('/admin/positions/final', {
|
||||
method: 'PUT',
|
||||
admin: true,
|
||||
body: { slots },
|
||||
})
|
||||
showToast(t('messages.saved'))
|
||||
} catch (error) {
|
||||
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function autoGroupEvenly() {
|
||||
if (primaryGroups.value.length === 0) {
|
||||
showToast(t('messages.noGroupsConfigured'), 'error')
|
||||
@@ -926,6 +1216,139 @@ async function updateLiveModeSetting(patch) {
|
||||
await updateAdminSettings({ liveMode: patch })
|
||||
}
|
||||
|
||||
function collectUniqueStrings(values) {
|
||||
const set = new Set()
|
||||
for (const raw of values || []) {
|
||||
const value = String(raw || '').trim()
|
||||
if (!value) continue
|
||||
set.add(value)
|
||||
}
|
||||
return [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
||||
}
|
||||
|
||||
function stageRoundMax(phase) {
|
||||
if (phase === 'preliminary') return 3
|
||||
if (phase === 'final') return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function mapStageSessionToScoreStage(stage) {
|
||||
if (!stage || typeof stage !== 'object') return ''
|
||||
const phase = String(stage.phase || '').trim().toLowerCase()
|
||||
const round = Number(stage.round || 1)
|
||||
if (phase === 'preliminary') return `prelim_r${Math.min(3, Math.max(1, Math.floor(round || 1)))}`
|
||||
if (phase === 'final') return `final_r${Math.min(2, Math.max(1, Math.floor(round || 1)))}`
|
||||
if (phase === 'prelim_tiebreak') return 'prelim_tiebreak'
|
||||
if (phase === 'final_tiebreak') return 'final_tiebreak'
|
||||
return ''
|
||||
}
|
||||
|
||||
function mapStageSessionToScoreGroup(stage) {
|
||||
if (!stage || typeof stage !== 'object') return ''
|
||||
const phase = String(stage.phase || '').trim().toLowerCase()
|
||||
const rawGroup = String(stage.group || '').trim()
|
||||
if (!rawGroup) return ''
|
||||
if (phase === 'preliminary') {
|
||||
if (rawGroup.toUpperCase() === 'UNASSIGNED') return 'UNASSIGNED'
|
||||
return normalizedGroupCode(rawGroup)
|
||||
}
|
||||
const parsed = Number.parseInt(rawGroup, 10)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return String(parsed)
|
||||
return rawGroup
|
||||
}
|
||||
|
||||
function mapStagePhaseToAdminTab(phase) {
|
||||
const normalized = String(phase || '').trim().toLowerCase()
|
||||
if (normalized === 'preliminary') return 'preliminary'
|
||||
if (normalized === 'prelim_tiebreak') return 'prelimTie'
|
||||
if (normalized === 'final') return 'final'
|
||||
if (normalized === 'final_tiebreak') return 'finalTie'
|
||||
return ''
|
||||
}
|
||||
|
||||
function applyStageControlDefaults() {
|
||||
const phase = String(stageControl.phase || 'preliminary').trim().toLowerCase()
|
||||
if (!['preliminary', 'prelim_tiebreak', 'final', 'final_tiebreak'].includes(phase)) {
|
||||
stageControl.phase = 'preliminary'
|
||||
} else {
|
||||
stageControl.phase = phase
|
||||
}
|
||||
|
||||
const maxRound = stageRoundMax(stageControl.phase)
|
||||
const round = Number(stageControl.round || 1)
|
||||
stageControl.round = Math.min(maxRound, Math.max(1, Number.isFinite(round) ? Math.floor(round) : 1))
|
||||
|
||||
const options = stageOptions.value?.[stageControl.phase] || []
|
||||
if (options.length === 0) {
|
||||
stageControl.group = ''
|
||||
return
|
||||
}
|
||||
const current = String(stageControl.group || '').trim()
|
||||
if (!options.includes(current)) {
|
||||
stageControl.group = options[0]
|
||||
}
|
||||
}
|
||||
|
||||
function updateStageControl(patch) {
|
||||
if (!patch || typeof patch !== 'object') return
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'phase')) {
|
||||
stageControl.phase = String(patch.phase || 'preliminary').trim().toLowerCase()
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'group')) {
|
||||
stageControl.group = String(patch.group || '').trim()
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'round')) {
|
||||
stageControl.round = Number(patch.round || 1)
|
||||
}
|
||||
applyStageControlDefaults()
|
||||
}
|
||||
|
||||
async function startCurrentStage() {
|
||||
applyStageControlDefaults()
|
||||
if (!stageControl.group) {
|
||||
showToast(t('messages.noGroupForPhase'), 'error')
|
||||
return
|
||||
}
|
||||
try {
|
||||
state.value = await api('/admin/stage/start', {
|
||||
method: 'POST',
|
||||
admin: true,
|
||||
body: {
|
||||
phase: stageControl.phase,
|
||||
group: stageControl.group,
|
||||
round: stageControl.round,
|
||||
},
|
||||
})
|
||||
showToast(t('messages.saved'))
|
||||
} catch (error) {
|
||||
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function endCurrentStage() {
|
||||
try {
|
||||
state.value = await api('/admin/stage/end', {
|
||||
method: 'POST',
|
||||
admin: true,
|
||||
})
|
||||
showToast(t('messages.saved'))
|
||||
} catch (error) {
|
||||
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToCurrentStage() {
|
||||
const active = currentStage.value
|
||||
if (!active) {
|
||||
showToast(t('labels.noCurrentStage'), 'error')
|
||||
return
|
||||
}
|
||||
const nextTab = mapStagePhaseToAdminTab(active.phase)
|
||||
if (nextTab) {
|
||||
adminTab.value = nextTab
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlayer() {
|
||||
if (!newPlayer.nameAr || !newPlayer.nameEn) {
|
||||
showToast(t('messages.mustProvideNames'), 'error')
|
||||
@@ -1209,7 +1632,7 @@ async function applySuggestedScore() {
|
||||
if (!scoreAdviceModal.advice) return
|
||||
const stage = scoreAdviceModal.stage
|
||||
const playerId = scoreAdviceModal.playerId
|
||||
const score = Number(scoreAdviceModal.advice.advisedScore || 0)
|
||||
const score = clampScoreValue(scoreAdviceModal.advice.advisedScore)
|
||||
await updateScoreValue(stage, playerId, score)
|
||||
scoreAdviceModal.currentScore = score
|
||||
closeScoreAdviceModal()
|
||||
@@ -1287,26 +1710,48 @@ function scoreDraftKey(stage, playerId) {
|
||||
return `${stage}:${playerId}`
|
||||
}
|
||||
|
||||
function clampScoreValue(value) {
|
||||
let score = Number(value)
|
||||
if (!Number.isFinite(score)) score = 0
|
||||
score = Math.trunc(score)
|
||||
if (score < 0) return 0
|
||||
if (score > 100) return 100
|
||||
return score
|
||||
}
|
||||
|
||||
function scoreInputValue(stage, playerId) {
|
||||
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
|
||||
if (draft) return draft.value
|
||||
return String(scoreFor(stage, playerId))
|
||||
}
|
||||
|
||||
function scoreInputInvalid(stage, playerId) {
|
||||
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
|
||||
return Boolean(draft?.invalid)
|
||||
}
|
||||
|
||||
function parseDraftScoreValue(value) {
|
||||
let score = Number(value === '' ? 0 : value)
|
||||
if (!Number.isFinite(score)) score = 0
|
||||
return Math.trunc(score)
|
||||
}
|
||||
|
||||
function onScoreFocus(stage, playerId) {
|
||||
const key = scoreDraftKey(stage, playerId)
|
||||
if (!scoreDrafts[key]) {
|
||||
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false }
|
||||
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
|
||||
}
|
||||
}
|
||||
|
||||
function onScoreInput(stage, playerId, event) {
|
||||
const key = scoreDraftKey(stage, playerId)
|
||||
const raw = String(event?.target?.value ?? '')
|
||||
const cleaned = raw.replace(/[^\d]/g, '').slice(0, 4)
|
||||
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false }
|
||||
scoreDrafts[key] = { ...current, value: cleaned }
|
||||
if (event?.target && event.target.value !== cleaned) event.target.value = cleaned
|
||||
const digits = raw.replace(/[^\d]/g, '').slice(0, 3)
|
||||
const parsed = parseDraftScoreValue(digits)
|
||||
const invalid = digits !== '' && (parsed < 0 || parsed > 100)
|
||||
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
|
||||
scoreDrafts[key] = { ...current, value: digits, invalid }
|
||||
if (event?.target && event.target.value !== digits) event.target.value = digits
|
||||
}
|
||||
|
||||
async function onScoreCommit(stage, playerId) {
|
||||
@@ -1314,11 +1759,11 @@ async function onScoreCommit(stage, playerId) {
|
||||
const draft = scoreDrafts[key]
|
||||
if (!draft || draft.committing) return
|
||||
|
||||
let score = Number(draft.value === '' ? 0 : draft.value)
|
||||
if (!Number.isFinite(score)) score = 0
|
||||
score = Math.trunc(score)
|
||||
const score = parseDraftScoreValue(draft.value)
|
||||
const invalid = score < 0 || score > 100
|
||||
|
||||
if (score < 0 || score > 9999) {
|
||||
if (invalid) {
|
||||
scoreDrafts[key] = { ...draft, invalid: true }
|
||||
showToast(t('messages.invalidScore'), 'error')
|
||||
return
|
||||
}
|
||||
@@ -1328,12 +1773,12 @@ async function onScoreCommit(stage, playerId) {
|
||||
return
|
||||
}
|
||||
|
||||
scoreDrafts[key] = { ...draft, committing: true }
|
||||
scoreDrafts[key] = { ...draft, invalid: false, committing: true }
|
||||
await updateScoreValue(stage, playerId, score, key)
|
||||
}
|
||||
|
||||
async function updateScoreValue(stage, playerId, score, draftKey = '') {
|
||||
if (score < 0 || score > 9999) {
|
||||
if (score < 0 || score > 100) {
|
||||
showToast(t('messages.invalidScore'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,7 +59,20 @@
|
||||
<h2>{{ t('liveMode') }}</h2>
|
||||
<p>{{ t('labels.liveModeVisibility') }}</p>
|
||||
</div>
|
||||
<LiveSettingsCard :t="t" :live-settings="liveSettings" :assignable-groups="assignableGroups" @update-live-setting="$emit('update-live-setting', $event)" />
|
||||
<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">
|
||||
@@ -81,6 +94,18 @@
|
||||
/>
|
||||
</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"
|
||||
@@ -92,12 +117,17 @@
|
||||
: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"
|
||||
@@ -105,6 +135,7 @@
|
||||
:score-proof-for="scoreProofFor"
|
||||
:open-score-proof-uploader="openScoreProofUploader"
|
||||
:open-proof-preview="openProofPreview"
|
||||
:show-proof-controls="false"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -127,18 +158,33 @@
|
||||
<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"
|
||||
@@ -148,6 +194,9 @@
|
||||
: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>
|
||||
@@ -161,6 +210,7 @@
|
||||
|
||||
<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">
|
||||
@@ -172,6 +222,18 @@
|
||||
/>
|
||||
</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"
|
||||
@@ -182,12 +244,17 @@
|
||||
: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"
|
||||
@@ -195,6 +262,7 @@
|
||||
:score-proof-for="scoreProofFor"
|
||||
:open-score-proof-uploader="openScoreProofUploader"
|
||||
:open-proof-preview="openProofPreview"
|
||||
:show-proof-controls="false"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -207,23 +275,38 @@
|
||||
<div class="hint-box danger" v-if="finalTie.required && !finalTie.resolved">{{ t('messages.finalTieUnresolved') }}</div>
|
||||
<div class="empty-state good" v-if="!finalTie.required">{{ t('messages.noFinalTie') }}</div>
|
||||
|
||||
<template v-if="finalTie.required">
|
||||
<div class="panel-actions">
|
||||
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final_tiebreak')">{{ t('actions.resetScores') }}</button>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
@@ -233,6 +316,9 @@
|
||||
: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>
|
||||
@@ -283,6 +369,7 @@ 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 },
|
||||
@@ -290,6 +377,12 @@ defineProps({
|
||||
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 },
|
||||
@@ -309,10 +402,12 @@ defineProps({
|
||||
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 },
|
||||
@@ -329,6 +424,10 @@ defineEmits([
|
||||
'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',
|
||||
@@ -345,6 +444,8 @@ defineEmits([
|
||||
'remove-player-image',
|
||||
'delete-player',
|
||||
'request-reset-stage',
|
||||
'reset-final-group-by-seed',
|
||||
'save-position-board',
|
||||
'update-score-filter',
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<div class="masthead-main">
|
||||
<div class="masthead-brand">
|
||||
<picture>
|
||||
<source srcset="/logo.svg" type="image/svg+xml" />
|
||||
<img class="masthead-logo" src="/logo.png" :alt="competitionTitle" />
|
||||
<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>
|
||||
@@ -16,6 +16,35 @@
|
||||
<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">
|
||||
@@ -23,7 +52,7 @@
|
||||
<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 === '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>
|
||||
@@ -57,9 +86,10 @@ const props = defineProps({
|
||||
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'])
|
||||
const emit = defineEmits(['change-mode', 'change-language', 'toggle-fullscreen'])
|
||||
|
||||
const controlsOpen = ref(false)
|
||||
const controlsRoot = ref(null)
|
||||
@@ -71,6 +101,7 @@ const modeShort = computed(() => {
|
||||
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')
|
||||
@@ -91,6 +122,10 @@ function changeLanguage(nextLanguage) {
|
||||
controlsOpen.value = false
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
emit('toggle-fullscreen')
|
||||
}
|
||||
|
||||
function onGlobalPointerDown(event) {
|
||||
if (!controlsOpen.value) return
|
||||
const root = controlsRoot.value
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<template>
|
||||
<div class="live-mode-root">
|
||||
<section class="live-mode-shell">
|
||||
<div class="live-mode-overlay">
|
||||
<header class="live-mode-header">
|
||||
<h2>{{ t('sections.liveModeTitle') }}</h2>
|
||||
<p>{{ t('sections.liveModeSubtitle') }}</p>
|
||||
</header>
|
||||
|
||||
<div class="live-screen-head live-head-selection">
|
||||
<!-- <div class="live-screen-head live-head-selection">
|
||||
<span class="live-chip strong">{{ activeViewLabel }}</span>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="live-mode-grid single">
|
||||
<article v-if="activeView === 'group_live'" class="live-screen-card wide">
|
||||
@@ -30,7 +26,7 @@
|
||||
<table class="score-table live-score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('table.rank') }}</th>
|
||||
<th>{{ t('table.position') }}</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.round1') }}</th>
|
||||
<th>{{ t('table.round2') }}</th>
|
||||
@@ -39,14 +35,13 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id">
|
||||
<td class="mono rank">{{ entry.rank }}</td>
|
||||
<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>
|
||||
<p class="name-sub">{{ secondaryName(entry.player) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -68,41 +63,41 @@
|
||||
<h3>{{ t('sections.prelimTieTitle') }}</h3>
|
||||
<div class="live-chip-row">
|
||||
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
||||
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
||||
<span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="live-swap" mode="out-in">
|
||||
<div :key="'prelim-tie-page-' + listPageIndex" class="live-swap-block">
|
||||
<article v-if="activePrelimTieGroup" :key="'live-pre-tie-group-' + activePrelimTieGroup.groupKey" class="live-tie-group-card">
|
||||
<h4 class="live-tie-group-title">{{ t('labels.group') }} {{ activePrelimTieGroup.groupKey }}</h4>
|
||||
<div class="table-wrap">
|
||||
<table class="score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{{ t('table.position') }}</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.tieScore') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in pagedPrelimTieRows" :key="'live-pre-tie-' + row.playerId">
|
||||
<td class="mono">{{ listPageStart + index + 1 }}</td>
|
||||
<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(row)" :alt="row.nameAr" class="competitor-image" />
|
||||
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
|
||||
<div>
|
||||
<p class="name-main">{{ displayName(row) }}</p>
|
||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||
<p class="name-main">{{ displayName(entry.row) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono strong">{{ row.tieBreak }}</td>
|
||||
<td class="mono strong">{{ entry.row.tieBreak }}</td>
|
||||
</tr>
|
||||
<tr v-if="prelimTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noPrelimTie') }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
@@ -130,22 +125,25 @@
|
||||
<tr
|
||||
v-for="row in pagedPreliminaryRows"
|
||||
:key="'live-pre-overall-' + row.playerId"
|
||||
:class="{ 'qualified-row': isFinalist(row.playerId) }"
|
||||
>
|
||||
<td class="mono rank">{{ row.rank }}</td>
|
||||
<td 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>
|
||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono strong">{{ row.score }}</td>
|
||||
<td class="mono">{{ row.tieBreak }}</td>
|
||||
</tr>
|
||||
<tr v-if="preliminaryRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
|
||||
<tr v-if="preliminaryRowsForLive.length === 0"><td colspan="4" class="muted center">{{ t('labels.noPlayers') }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -164,15 +162,15 @@
|
||||
<Transition name="live-swap" mode="out-in">
|
||||
<div :key="'live-final-swap-' + liveFinalGroup" class="live-swap-block">
|
||||
<div class="live-focus-banner focus-final">
|
||||
<span class="live-focus-label">{{ t('tabs.final') }}</span>
|
||||
<strong class="live-focus-title">{{ currentFinalGroupLabel }}</strong>
|
||||
<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.rank') }}</th>
|
||||
<th>{{ t('table.position') }}</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.round1') }}</th>
|
||||
<th>{{ t('table.round2') }}</th>
|
||||
@@ -180,14 +178,13 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId">
|
||||
<td class="mono rank">{{ entry.rank }}</td>
|
||||
<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>
|
||||
<p class="name-sub">{{ secondaryName(entry.row) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -208,36 +205,82 @@
|
||||
<h3>{{ t('sections.finalTieTitle') }}</h3>
|
||||
<div class="live-chip-row">
|
||||
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
||||
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
||||
<span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="live-swap" mode="out-in">
|
||||
<div :key="'final-tie-page-' + listPageIndex" class="live-swap-block">
|
||||
<article v-if="activeFinalTieGroup" :key="'live-final-tie-group-' + activeFinalTieGroup.groupKey" class="live-tie-group-card">
|
||||
<h4 class="live-tie-group-title">{{ t('labels.group') }} {{ activeFinalTieGroup.groupKey }}</h4>
|
||||
<div class="table-wrap">
|
||||
<table class="score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{{ t('table.position') }}</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.tieScore') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in pagedFinalTieRows" :key="'live-final-tie-' + row.playerId">
|
||||
<td class="mono">{{ listPageStart + index + 1 }}</td>
|
||||
<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>
|
||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono strong">{{ row.tieBreak }}</td>
|
||||
<td class="mono strong">{{ row.score }}</td>
|
||||
|
||||
</tr>
|
||||
<tr v-if="finalTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noFinalTie') }}</td></tr>
|
||||
<tr v-if="finalOverallRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -256,7 +299,7 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -268,7 +311,7 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -280,7 +323,7 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -290,7 +333,24 @@
|
||||
</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>
|
||||
@@ -299,18 +359,20 @@ 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 },
|
||||
secondaryName: { type: Function, required: true },
|
||||
scoreFor: { type: Function, required: true },
|
||||
prelimTotal: { type: Function, required: true },
|
||||
finalTotal: { type: Function, required: true },
|
||||
@@ -322,9 +384,14 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const finalistIds = computed(() => new Set((props.finalists || []).map((row) => row.playerId)))
|
||||
const 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)
|
||||
@@ -340,10 +407,11 @@ const rotationPlayerCount = computed(() => {
|
||||
return Math.floor(raw)
|
||||
})
|
||||
|
||||
const preliminaryRowsForLive = computed(() => rankPreliminaryRowsWithTieBreak(props.preliminaryRows || []))
|
||||
|
||||
const listRowsForActiveView = computed(() => {
|
||||
if (activeView.value === 'prelim_tie') return props.prelimTieRows || []
|
||||
if (activeView.value === 'prelim_overall') return props.preliminaryRows || []
|
||||
if (activeView.value === 'final_tie') return props.finalTieRows || []
|
||||
if (activeView.value === 'prelim_overall') return preliminaryRowsForLive.value
|
||||
if (activeView.value === 'final_overall') return props.finalOverallRows || []
|
||||
return []
|
||||
})
|
||||
|
||||
@@ -353,30 +421,37 @@ const listPageCount = computed(() => {
|
||||
return Math.max(1, Math.ceil(total / rotationPlayerCount.value))
|
||||
})
|
||||
|
||||
const listPageStart = computed(() => listPageIndex.value * rotationPlayerCount.value)
|
||||
const currentListPageDisplay = computed(() => `${listPageIndex.value + 1} / ${listPageCount.value}`)
|
||||
|
||||
const pagedPrelimTieRows = computed(() => paginateRows(props.prelimTieRows || []))
|
||||
const pagedPreliminaryRows = computed(() => paginateRows(props.preliminaryRows || []))
|
||||
const pagedFinalTieRows = computed(() => paginateRows(props.finalTieRows || []))
|
||||
const 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(() => {
|
||||
const rows = (props.liveGroupMembers || []).map((player) => ({
|
||||
return (props.liveGroupMembers || []).map((player) => ({
|
||||
player,
|
||||
total: Number(props.prelimTotal(player.id) || 0),
|
||||
}))
|
||||
rows.sort((a, b) => {
|
||||
if (a.total !== b.total) return b.total - a.total
|
||||
return a.player.id - b.player.id
|
||||
})
|
||||
|
||||
let prevTotal = null
|
||||
let prevRank = 0
|
||||
return rows.map((entry, idx) => {
|
||||
const rank = prevTotal === entry.total ? prevRank : idx + 1
|
||||
prevTotal = entry.total
|
||||
prevRank = rank
|
||||
return { ...entry, rank }
|
||||
})
|
||||
})
|
||||
|
||||
const activeViewLabel = computed(() => {
|
||||
@@ -386,15 +461,17 @@ const activeViewLabel = computed(() => {
|
||||
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 currentFinalGroupLabel = computed(() => {
|
||||
if (props.liveFinalGroup === 2) return props.t('labels.finalGroup2')
|
||||
return props.t('labels.finalGroup1')
|
||||
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'
|
||||
@@ -404,44 +481,48 @@ const groupClassKey = computed(() => {
|
||||
return 'u'
|
||||
})
|
||||
const rankedLiveFinalRows = computed(() => {
|
||||
const rows = (props.liveFinalRows || []).map((row) => ({
|
||||
return (props.liveFinalRows || []).map((row) => ({
|
||||
row,
|
||||
total: Number(props.finalTotal(row.playerId) || 0),
|
||||
}))
|
||||
|
||||
rows.sort((a, b) => {
|
||||
if (a.total !== b.total) return b.total - a.total
|
||||
const seedA = Number(a.row.seed || 0)
|
||||
const seedB = Number(b.row.seed || 0)
|
||||
if (seedA !== seedB) return seedA - seedB
|
||||
return a.row.playerId - b.row.playerId
|
||||
})
|
||||
|
||||
let prevTotal = null
|
||||
let prevRank = 0
|
||||
return rows.map((entry, idx) => {
|
||||
const rank = prevTotal === entry.total ? prevRank : idx + 1
|
||||
prevTotal = entry.total
|
||||
prevRank = rank
|
||||
return { ...entry, rank }
|
||||
})
|
||||
})
|
||||
|
||||
function isFinalist(playerId) {
|
||||
return finalistIds.value.has(playerId)
|
||||
}
|
||||
|
||||
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.finalTieRows.length}`,
|
||||
`${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) {
|
||||
@@ -449,11 +530,36 @@ function paginateRows(rows) {
|
||||
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_tie' || activeView.value === 'prelim_overall' || activeView.value === 'final_tie'
|
||||
const autoRotateView = activeView.value === 'prelim_overall' || activeView.value === 'final_overall'
|
||||
if (!autoRotateView) return
|
||||
if (listPageCount.value <= 1) return
|
||||
|
||||
@@ -472,4 +578,62 @@ function stopListRotation() {
|
||||
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>
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
<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>
|
||||
<!-- <p class="modal-text subtle">{{ t('messages.resetProofPrompt') }}</p> -->
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" @click="$emit('confirm', false)">{{ t('actions.resetOnlyScores') }}</button>
|
||||
<button class="btn btn-danger" @click="$emit('confirm', true)">{{ t('actions.resetScoresAndProofs') }}</button>
|
||||
<button class="btn btn-secondary" @click="$emit('cancel')">{{ t('actions.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<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>
|
||||
@@ -77,19 +78,126 @@
|
||||
@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'])
|
||||
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>
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
/>
|
||||
</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>
|
||||
@@ -18,13 +32,19 @@
|
||||
<th v-if="showGroup">{{ t('table.group') }}</th>
|
||||
<th v-if="showRank">{{ t('table.rank') }}</th>
|
||||
<th v-if="showSeed">{{ t('table.seed') }}</th>
|
||||
<th v-for="round in roundStages" :key="'head-' + round.stage">{{ round.label }}</th>
|
||||
<th
|
||||
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>{{ t('table.verification') }}</th>
|
||||
<th v-if="showProofControls">{{ t('table.verification') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in filteredRows" :key="'multi-row-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)">
|
||||
<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">
|
||||
@@ -38,14 +58,20 @@
|
||||
<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">
|
||||
<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="9999"
|
||||
max="100"
|
||||
:value="scoreInputValue(round.stage, row.playerId)"
|
||||
@focus="onScoreFocus(round.stage, row.playerId)"
|
||||
@input="onScoreInput(round.stage, row.playerId, $event)"
|
||||
@@ -54,7 +80,7 @@
|
||||
/>
|
||||
</td>
|
||||
<td class="mono strong">{{ totalScore(row.playerId) }}</td>
|
||||
<td class="verify-round-cell">
|
||||
<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>
|
||||
@@ -72,15 +98,12 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filteredRows.length === 0">
|
||||
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mobile-score-cards">
|
||||
<article v-for="(row, index) in filteredRows" :key="'multi-mobile-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row)">
|
||||
<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" />
|
||||
@@ -99,15 +122,21 @@
|
||||
</div>
|
||||
|
||||
<div class="mobile-round-grid">
|
||||
<label v-for="round in roundStages" :key="'mobile-round-' + round.stage + '-' + row.playerId" class="mobile-round-item">
|
||||
<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="9999"
|
||||
max="100"
|
||||
:value="scoreInputValue(round.stage, row.playerId)"
|
||||
@focus="onScoreFocus(round.stage, row.playerId)"
|
||||
@input="onScoreInput(round.stage, row.playerId, $event)"
|
||||
@@ -122,7 +151,7 @@
|
||||
<strong class="mono">{{ totalScore(row.playerId) }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="mobile-verify-block">
|
||||
<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">
|
||||
@@ -141,14 +170,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { scoreGroupStyle } from '../../utils/scoreGroupTheme'
|
||||
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 },
|
||||
@@ -160,10 +193,14 @@ const props = defineProps({
|
||||
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 },
|
||||
@@ -171,10 +208,14 @@ const props = defineProps({
|
||||
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
|
||||
@@ -185,22 +226,144 @@ const filteredRows = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const columnCount = computed(() => {
|
||||
let count = 4 + props.roundStages.length
|
||||
if (props.showGroup) count += 1
|
||||
if (props.showRank) count += 1
|
||||
if (props.showSeed) count += 1
|
||||
return count
|
||||
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 scoreGroupStyleFor(row) {
|
||||
function sectionStyleFor(key, sampleRow) {
|
||||
if (key.startsWith('B')) return groupThemeStyleForKey(key)
|
||||
return scoreGroupStyle(sampleRow)
|
||||
}
|
||||
|
||||
function scoreGroupStyleFor(row, section) {
|
||||
if (section?.key?.startsWith('B') && section?.style) return section.style
|
||||
return scoreGroupStyle(row)
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -83,10 +83,15 @@
|
||||
<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">
|
||||
<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">
|
||||
@@ -131,14 +136,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||
<div v-else-if="group.players.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
t: { type: Function, required: true },
|
||||
@@ -227,6 +232,29 @@ const groupedCards = computed(() => {
|
||||
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
|
||||
|
||||
249
frontend/src/components/admin/PositionBoardEditor.vue
Normal file
249
frontend/src/components/admin/PositionBoardEditor.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<div class="position-board">
|
||||
<div class="position-board-head">
|
||||
<button class="position-board-toggle" type="button" @click="collapsedAll = !collapsedAll">
|
||||
<span class="position-board-title">{{ t('labels.groupAssignment') }}</span>
|
||||
<span class="position-board-meta">{{ columns.length }} • {{ collapsedAll ? '+' : '−' }}</span>
|
||||
</button>
|
||||
<div v-if="hasOverflowColumns" class="position-board-alert">{{ t('labels.group') }} > 6</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!collapsedAll" class="position-board-grid">
|
||||
<section
|
||||
v-for="column in columns"
|
||||
:key="board + '-col-' + column.key"
|
||||
class="position-column"
|
||||
:class="{ 'position-column-overflow': shouldHighlightOverflow && column.items.length > 6 }"
|
||||
>
|
||||
<header class="position-column-head">
|
||||
<div class="position-column-toggle">
|
||||
<span class="position-column-title">{{ columnLabel(column.key) }}</span>
|
||||
<span class="position-column-count" :class="{ danger: shouldHighlightOverflow && column.items.length > 6 }">{{ column.items.length }}</span>
|
||||
</div>
|
||||
<div v-if="shouldHighlightOverflow && column.items.length > 6" class="position-column-alert">
|
||||
{{ t('labels.group') }} {{ column.key }} > 6
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="position-column-list" @dragover.prevent @drop="onDropAtEnd(column.key)">
|
||||
<article
|
||||
v-for="(item, index) in column.items"
|
||||
:key="board + '-card-' + item.playerId"
|
||||
class="position-card"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, column.key, index, item.playerId)"
|
||||
@dragover.prevent
|
||||
@drop="onDropBefore(column.key, index)"
|
||||
>
|
||||
<div class="position-badge">{{ index + 1 }}</div>
|
||||
<img :src="playerImage(item)" :alt="item.nameAr" class="position-avatar" />
|
||||
<div class="position-card-text">
|
||||
<p class="name-main">{{ displayName(item) }}</p>
|
||||
<p class="name-sub">{{ secondaryName(item) }}</p>
|
||||
<p v-if="scoreText(item)" class="position-score">{{ scoreText(item) }}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="column.items.length === 0" class="position-empty" @dragover.prevent @drop="onDropAtEnd(column.key)">
|
||||
{{ t('labels.noPlayers') }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="position-actions" v-if="isNumericBoard">
|
||||
<button class="btn btn-outline btn-xs" @click="addNumericGroup">+ {{ t('labels.group') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
t: { type: Function, required: true },
|
||||
board: { type: String, required: true },
|
||||
rows: { type: Array, required: true },
|
||||
slots: { type: Object, default: () => ({}) },
|
||||
playerImage: { type: Function, required: true },
|
||||
displayName: { type: Function, required: true },
|
||||
secondaryName: { type: Function, required: true },
|
||||
groupLabelPrefix: { type: String, default: '' },
|
||||
numericGroups: { type: Boolean, default: false },
|
||||
availableGroups: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save'])
|
||||
|
||||
const columns = ref([])
|
||||
const dragging = ref(null)
|
||||
const collapsedAll = ref(false)
|
||||
|
||||
const isNumericBoard = computed(() => props.numericGroups)
|
||||
const shouldHighlightOverflow = computed(
|
||||
() =>
|
||||
props.board === 'preliminary' ||
|
||||
props.board === 'final' ||
|
||||
props.board === 'prelim_tiebreak' ||
|
||||
props.board === 'final_tiebreak',
|
||||
)
|
||||
const hasOverflowColumns = computed(
|
||||
() => shouldHighlightOverflow.value && columns.value.some((column) => Number(column?.items?.length || 0) > 6),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => `${props.board}|${props.rows.map((row) => row.playerId).join('|')}|${JSON.stringify(props.slots || {})}`,
|
||||
() => {
|
||||
rebuildColumns()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function rebuildColumns() {
|
||||
const mapped = (props.rows || []).map((row, idx) => {
|
||||
const slot = props.slots?.[String(row.playerId)] || props.slots?.[row.playerId] || null
|
||||
return {
|
||||
...row,
|
||||
slotGroup: normalizeGroup(slot?.groupKey, row, idx),
|
||||
slotPosition: normalizePosition(slot?.position, idx + 1),
|
||||
}
|
||||
})
|
||||
|
||||
const byGroup = new Map()
|
||||
for (const item of mapped) {
|
||||
if (!byGroup.has(item.slotGroup)) byGroup.set(item.slotGroup, [])
|
||||
byGroup.get(item.slotGroup).push(item)
|
||||
}
|
||||
|
||||
const groupKeys = [...byGroup.keys()].sort(compareGroupKeys)
|
||||
if (!isNumericBoard.value) {
|
||||
for (const group of props.availableGroups || []) {
|
||||
const normalized = String(group || '').trim().toUpperCase()
|
||||
if (!normalized) continue
|
||||
if (!groupKeys.includes(normalized)) groupKeys.push(normalized)
|
||||
}
|
||||
groupKeys.sort(compareGroupKeys)
|
||||
}
|
||||
let nextColumns = groupKeys.map((key) => {
|
||||
const items = byGroup.get(key)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.slotPosition !== b.slotPosition) return a.slotPosition - b.slotPosition
|
||||
return a.playerId - b.playerId
|
||||
})
|
||||
return { key, items }
|
||||
})
|
||||
|
||||
columns.value = nextColumns
|
||||
}
|
||||
|
||||
function normalizeGroup(groupKey, row, idx) {
|
||||
if (isNumericBoard.value) {
|
||||
const parsed = Number.parseInt(String(groupKey || ''), 10)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return String(parsed)
|
||||
const rowScoreGroup = Number.parseInt(String(row?.scoreGroup || ''), 10)
|
||||
if (Number.isFinite(rowScoreGroup) && rowScoreGroup > 0) return String(rowScoreGroup)
|
||||
const rowFinalGroup = Number.parseInt(String(row?.finalGroup || ''), 10)
|
||||
if (Number.isFinite(rowFinalGroup) && rowFinalGroup > 0) return String(rowFinalGroup)
|
||||
return '1'
|
||||
}
|
||||
|
||||
const value = String(groupKey || row.groupCode || '').trim().toUpperCase()
|
||||
return value || 'UNASSIGNED'
|
||||
}
|
||||
|
||||
function normalizePosition(position, fallback) {
|
||||
const parsed = Number(position)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed)
|
||||
return fallback
|
||||
}
|
||||
|
||||
function compareGroupKeys(a, b) {
|
||||
if (isNumericBoard.value) {
|
||||
return Number(a) - Number(b)
|
||||
}
|
||||
if (a === 'UNASSIGNED') return 1
|
||||
if (b === 'UNASSIGNED') return -1
|
||||
return a.localeCompare(b)
|
||||
}
|
||||
|
||||
function columnLabel(key) {
|
||||
if (isNumericBoard.value) {
|
||||
return `${props.t('labels.group')} ${key}`
|
||||
}
|
||||
if (key === 'UNASSIGNED') return props.t('labels.unassigned')
|
||||
return `${props.groupLabelPrefix || props.t('labels.group')} ${key}`
|
||||
}
|
||||
|
||||
function scoreText(item) {
|
||||
if (props.board === 'preliminary') return `${props.t('table.total')}: ${Number(item.score || 0)}`
|
||||
if (props.board === 'final') return `${props.t('table.total')}: ${Number(item.score || 0)}`
|
||||
return `${props.t('table.tieScore')}: ${Number(item.tieBreak || 0)}`
|
||||
}
|
||||
|
||||
function onDragStart(event, groupKey, index, playerId) {
|
||||
dragging.value = { groupKey, index, playerId }
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function onDropBefore(targetGroupKey, targetIndex) {
|
||||
moveDragged(targetGroupKey, targetIndex)
|
||||
}
|
||||
|
||||
function onDropAtEnd(targetGroupKey) {
|
||||
const column = columns.value.find((entry) => entry.key === targetGroupKey)
|
||||
const targetIndex = column ? column.items.length : 0
|
||||
moveDragged(targetGroupKey, targetIndex)
|
||||
}
|
||||
|
||||
function moveDragged(targetGroupKey, targetIndex) {
|
||||
const drag = dragging.value
|
||||
if (!drag) return
|
||||
|
||||
const sourceCol = columns.value.find((entry) => entry.key === drag.groupKey)
|
||||
const targetCol = columns.value.find((entry) => entry.key === targetGroupKey)
|
||||
if (!sourceCol || !targetCol) {
|
||||
dragging.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const sourceIndex = sourceCol.items.findIndex((item) => item.playerId === drag.playerId)
|
||||
if (sourceIndex < 0) {
|
||||
dragging.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const [moved] = sourceCol.items.splice(sourceIndex, 1)
|
||||
let insertAt = targetIndex
|
||||
if (sourceCol.key === targetCol.key && sourceIndex < targetIndex) {
|
||||
insertAt -= 1
|
||||
}
|
||||
if (insertAt < 0) insertAt = 0
|
||||
if (insertAt > targetCol.items.length) insertAt = targetCol.items.length
|
||||
targetCol.items.splice(insertAt, 0, moved)
|
||||
|
||||
dragging.value = null
|
||||
emitSave()
|
||||
}
|
||||
|
||||
function emitSave() {
|
||||
const slots = []
|
||||
for (const column of columns.value) {
|
||||
column.items.forEach((item, idx) => {
|
||||
slots.push({
|
||||
playerId: item.playerId,
|
||||
groupKey: column.key,
|
||||
position: idx + 1,
|
||||
})
|
||||
})
|
||||
}
|
||||
emit('save', { board: props.board, slots })
|
||||
}
|
||||
|
||||
function addNumericGroup() {
|
||||
if (!isNumericBoard.value) return
|
||||
const keys = columns.value.map((entry) => Number(entry.key)).filter((value) => Number.isFinite(value) && value > 0)
|
||||
const next = keys.length > 0 ? Math.max(...keys) + 1 : 1
|
||||
columns.value = [...columns.value, { key: String(next), items: [] }]
|
||||
}
|
||||
</script>
|
||||
@@ -9,6 +9,13 @@
|
||||
/>
|
||||
</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>
|
||||
@@ -17,14 +24,19 @@
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th v-if="showGroup">{{ t('table.group') }}</th>
|
||||
<th v-if="showScoreBeforeInput">{{ t('table.score') }}</th>
|
||||
<th>{{ inputLabel }}</th>
|
||||
<th>{{ t('table.verification') }}</th>
|
||||
<th :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 filteredRows" :key="'desk-' + stage + '-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)">
|
||||
<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">
|
||||
@@ -37,14 +49,15 @@
|
||||
</td>
|
||||
<td v-if="showGroup" class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
|
||||
<td v-if="showScoreBeforeInput" class="mono strong">{{ row.score }}</td>
|
||||
<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="9999"
|
||||
max="100"
|
||||
:value="scoreInputValue(stage, row.playerId)"
|
||||
@focus="onScoreFocus(stage, row.playerId)"
|
||||
@input="onScoreInput(stage, row.playerId, $event)"
|
||||
@@ -52,42 +65,21 @@
|
||||
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="proof-actions">
|
||||
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
|
||||
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||
</button>
|
||||
<!-- <button
|
||||
v-if="hasScoreProof(stage, row.playerId)"
|
||||
class="btn btn-ai btn-xs"
|
||||
@click="requestScoreAdvice(stage, row.playerId)"
|
||||
>
|
||||
{{ t('actions.aiAdvisor') }}
|
||||
</button> -->
|
||||
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
|
||||
{{ t('actions.removeProof') }}
|
||||
</button>
|
||||
<img
|
||||
v-if="hasScoreProof(stage, row.playerId)"
|
||||
class="proof-thumb"
|
||||
:src="scoreProofFor(stage, row.playerId)"
|
||||
:alt="t('table.verification')"
|
||||
@click="openProofPreview(stage, row.playerId)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
|
||||
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
|
||||
</tr>
|
||||
<tr v-if="filteredRows.length === 0">
|
||||
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mobile-score-cards">
|
||||
<article v-for="(row, index) in filteredRows" :key="'mob-' + stage + '-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row)">
|
||||
<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" />
|
||||
@@ -106,14 +98,15 @@
|
||||
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
|
||||
</div>
|
||||
|
||||
<label class="score-label">{{ inputLabel }}</label>
|
||||
<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="9999"
|
||||
max="100"
|
||||
:value="scoreInputValue(stage, row.playerId)"
|
||||
@focus="onScoreFocus(stage, row.playerId)"
|
||||
@input="onScoreInput(stage, row.playerId, $event)"
|
||||
@@ -121,18 +114,10 @@
|
||||
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
|
||||
/>
|
||||
|
||||
<div class="proof-actions mobile-proof-actions">
|
||||
<div v-if="showProofControls" class="proof-actions mobile-proof-actions">
|
||||
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
|
||||
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||
</button>
|
||||
<!-- <button
|
||||
v-if="hasScoreProof(stage, row.playerId)"
|
||||
class="btn btn-ai btn-xs"
|
||||
@click="requestScoreAdvice(stage, row.playerId)"
|
||||
>
|
||||
{{ t('actions.aiAdvisor') }}
|
||||
</button> -->
|
||||
|
||||
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
|
||||
{{ t('actions.removeProof') }}
|
||||
</button>
|
||||
@@ -145,14 +130,18 @@
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { scoreGroupStyle, scoreValueStyle } from '../../utils/scoreGroupTheme'
|
||||
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 },
|
||||
@@ -166,10 +155,14 @@ const props = defineProps({
|
||||
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 },
|
||||
@@ -179,10 +172,14 @@ const props = defineProps({
|
||||
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
|
||||
@@ -193,19 +190,151 @@ const filteredRows = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const columnCount = computed(() => {
|
||||
let count = 4
|
||||
if (props.showGroup) count += 1
|
||||
if (props.showScoreBeforeInput) count += 1
|
||||
if (props.showRank) count += 1
|
||||
if (props.showSeed) count += 1
|
||||
return count
|
||||
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]),
|
||||
}))
|
||||
})
|
||||
|
||||
function scoreGroupStyleFor(row) {
|
||||
if (props.colorMode === 'score') {
|
||||
return scoreValueStyle(row?.score)
|
||||
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>
|
||||
|
||||
@@ -14,6 +14,7 @@ export const I18N = {
|
||||
liveTracker: 'LIVE TRACKER',
|
||||
mode: 'الوضع',
|
||||
language: 'اللغة',
|
||||
fullscreen: 'ملء الشاشة',
|
||||
lastSync: 'آخر مزامنة',
|
||||
loading: 'جاري تحميل بيانات البطولة...',
|
||||
players: 'لاعب',
|
||||
@@ -23,11 +24,14 @@ export const I18N = {
|
||||
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: 'إظهار صور إثبات النتيجة في وضع العرض',
|
||||
@@ -40,6 +44,7 @@ export const I18N = {
|
||||
liveViewPrelimOverall: 'الترتيب العام للتمهيدي',
|
||||
liveViewFinalGroups: 'شاشة مجموعات النهائي',
|
||||
liveViewFinalTie: 'كسر تعادل النهائي',
|
||||
liveViewFinalOverall: 'الترتيب العام للنهائي',
|
||||
liveViewPodium: 'منصة التتويج',
|
||||
showGroupLive: 'إظهار شاشة المجموعات الحية',
|
||||
showPrelimTie: 'إظهار كسر تعادل التمهيدي',
|
||||
@@ -56,6 +61,21 @@ export const I18N = {
|
||||
currentScore: 'النتيجة الحالية',
|
||||
aiSuggestedScore: 'النتيجة المقترحة',
|
||||
confidence: 'مستوى الثقة',
|
||||
stageControl: 'المرحلة الحالية',
|
||||
stagePhase: 'الطور',
|
||||
stageGroup: 'المجموعة',
|
||||
stageRound: 'الجولة',
|
||||
currentStage: 'المرحلة الجارية',
|
||||
lastStage: 'آخر مرحلة',
|
||||
startedAt: 'بدأت',
|
||||
endedAt: 'انتهت',
|
||||
noCurrentStage: 'لا توجد مرحلة جارية',
|
||||
noLastStage: 'لا توجد مرحلة سابقة',
|
||||
phasePreliminary: 'التمهيدي',
|
||||
phasePrelimTie: 'كسر تعادل التمهيدي',
|
||||
phaseFinal: 'النهائي',
|
||||
phaseFinalTie: 'كسر تعادل النهائي',
|
||||
groupAssignment: 'توزيع المجموعات',
|
||||
},
|
||||
sections: {
|
||||
groupsTitle: 'عرض اللاعبين والمجموعات',
|
||||
@@ -92,6 +112,7 @@ export const I18N = {
|
||||
competitor: 'اللاعب',
|
||||
group: 'المجموعة',
|
||||
rank: 'الترتيب',
|
||||
position: 'الموضع',
|
||||
score: 'النتيجة',
|
||||
total: 'المجموع',
|
||||
round1: 'جولة 1',
|
||||
@@ -120,6 +141,7 @@ export const I18N = {
|
||||
removeImage: 'حذف الصورة',
|
||||
delete: 'حذف',
|
||||
resetScores: 'تصفير نتائج المرحلة',
|
||||
resetFinalGroupBySeed: 'إعادة مجموعات النهائي حسب التصنيف (1-6 / 7-12)',
|
||||
resetOnlyScores: 'تصفير النتائج فقط',
|
||||
resetScoresAndProofs: 'تصفير النتائج وحذف الإثبات',
|
||||
randomEvenGroups: 'توزيع عشوائي متوازن',
|
||||
@@ -139,6 +161,11 @@ export const I18N = {
|
||||
resetPosition: 'إعادة الضبط',
|
||||
liveModeRotate: 'تدوير تلقائي',
|
||||
liveModeFixed: 'ثابت',
|
||||
enterFullscreen: 'دخول ملء الشاشة',
|
||||
exitFullscreen: 'الخروج من ملء الشاشة',
|
||||
startStage: 'بدء المرحلة',
|
||||
endStage: 'إنهاء المرحلة',
|
||||
goToStageScoring: 'الانتقال إلى إدخال نتائج المرحلة',
|
||||
},
|
||||
auth: {
|
||||
username: 'اسم المستخدم',
|
||||
@@ -166,13 +193,14 @@ export const I18N = {
|
||||
confirmDelete: 'هل تريد حذف اللاعب؟',
|
||||
confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟',
|
||||
resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟',
|
||||
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 9999.',
|
||||
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 100.',
|
||||
unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.',
|
||||
errorPrefix: 'حدث خطأ',
|
||||
noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.',
|
||||
noNameToConvert: 'لا يوجد اسم للتحويل.',
|
||||
aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...',
|
||||
noProofForAi: 'لا توجد صورة إثبات لتحليلها.',
|
||||
noGroupForPhase: 'لا توجد مجموعات متاحة لهذا الطور.',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
@@ -190,6 +218,7 @@ export const I18N = {
|
||||
liveTracker: 'LIVE TRACKER',
|
||||
mode: 'Mode',
|
||||
language: 'Language',
|
||||
fullscreen: 'Fullscreen',
|
||||
lastSync: 'Last sync',
|
||||
loading: 'Loading tournament data...',
|
||||
players: 'Players',
|
||||
@@ -199,11 +228,14 @@ export const I18N = {
|
||||
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',
|
||||
@@ -216,6 +248,7 @@ export const I18N = {
|
||||
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',
|
||||
@@ -232,6 +265,21 @@ export const I18N = {
|
||||
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',
|
||||
@@ -268,6 +316,7 @@ export const I18N = {
|
||||
competitor: 'Player',
|
||||
group: 'Group',
|
||||
rank: 'Rank',
|
||||
position: 'Position',
|
||||
score: 'Score',
|
||||
total: 'Total',
|
||||
round1: 'Round 1',
|
||||
@@ -296,6 +345,7 @@ export const I18N = {
|
||||
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',
|
||||
@@ -315,6 +365,11 @@ export const I18N = {
|
||||
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',
|
||||
@@ -342,13 +397,14 @@ export const I18N = {
|
||||
confirmDelete: 'Delete this player?',
|
||||
confirmReset: 'Reset this stage scores?',
|
||||
resetProofPrompt: 'Also remove all proof images for this stage?',
|
||||
invalidScore: 'Score must be between 0 and 9999.',
|
||||
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.',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -392,6 +448,8 @@ export function createInitialState() {
|
||||
finalRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
|
||||
podium: [],
|
||||
},
|
||||
current_stage: null,
|
||||
last_stage: null,
|
||||
serverTime: '',
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,24 @@
|
||||
import { normalizedGroupCode } from './groups'
|
||||
|
||||
const PRESET = {
|
||||
A: { accent: '#2f4d9a', soft: 'rgba(47, 77, 154, 0.10)', border: 'rgba(47, 77, 154, 0.34)' },
|
||||
B: { accent: '#0d8fa5', soft: 'rgba(13, 143, 165, 0.10)', border: 'rgba(13, 143, 165, 0.34)' },
|
||||
C: { accent: '#d54a3f', soft: 'rgba(213, 74, 63, 0.10)', border: 'rgba(213, 74, 63, 0.34)' },
|
||||
D: { accent: '#c2891f', soft: 'rgba(194, 137, 31, 0.12)', border: 'rgba(194, 137, 31, 0.36)' },
|
||||
F1: { accent: '#5b4fc9', soft: 'rgba(91, 79, 201, 0.11)', border: 'rgba(91, 79, 201, 0.36)' },
|
||||
F2: { accent: '#0f9f63', soft: 'rgba(15, 159, 99, 0.11)', border: 'rgba(15, 159, 99, 0.36)' },
|
||||
U: { accent: '#7f8ca8', soft: 'rgba(127, 140, 168, 0.11)', border: 'rgba(127, 140, 168, 0.34)' },
|
||||
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'
|
||||
@@ -30,12 +39,7 @@ export function scoreGroupStyle(row) {
|
||||
}
|
||||
}
|
||||
|
||||
const hue = hashToHue(token)
|
||||
return {
|
||||
'--score-group-accent': `hsl(${hue} 70% 38%)`,
|
||||
'--score-group-soft': `hsla(${hue}, 72%, 46%, 0.10)`,
|
||||
'--score-group-border': `hsla(${hue}, 70%, 38%, 0.34)`,
|
||||
}
|
||||
return groupThemeStyleForKey(token)
|
||||
}
|
||||
|
||||
export function scoreValueStyle(scoreValue) {
|
||||
@@ -49,6 +53,26 @@ export function scoreValueStyle(scoreValue) {
|
||||
}
|
||||
}
|
||||
|
||||
export function groupThemeStyleForKey(key, options = {}) {
|
||||
const raw = String(key || '').trim().toUpperCase()
|
||||
const compact = raw.replace(/^[A-Z]/, '')
|
||||
const numeric = Number.parseInt(compact || raw, 10)
|
||||
const idx = Number.isFinite(numeric) && numeric > 0 ? (numeric - 1) % THEME_PALETTE.length : hashToHue(raw || 'U') % THEME_PALETTE.length
|
||||
const base = THEME_PALETTE[idx]
|
||||
if (!options.tieBreak) {
|
||||
return {
|
||||
'--score-group-accent': base.accent,
|
||||
'--score-group-soft': base.soft,
|
||||
'--score-group-border': base.border,
|
||||
}
|
||||
}
|
||||
return {
|
||||
'--score-group-accent': base.accent,
|
||||
'--score-group-soft': base.soft.replace('0.15', '0.2'),
|
||||
'--score-group-border': base.border,
|
||||
}
|
||||
}
|
||||
|
||||
function hashToHue(value) {
|
||||
const input = String(value || 'U')
|
||||
let hash = 0
|
||||
|
||||
137
getdata.json
Normal file
137
getdata.json
Normal file
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"response": {
|
||||
"protocolVersion": {
|
||||
"protocol": "HTTP",
|
||||
"minor": 1,
|
||||
"major": 1
|
||||
},
|
||||
"allHeaders": [],
|
||||
"statusLine": {
|
||||
"statusCode": 200,
|
||||
"protocolVersion": {
|
||||
"protocol": "HTTP",
|
||||
"minor": 1,
|
||||
"major": 1
|
||||
},
|
||||
"reasonPhrase": "OK"
|
||||
},
|
||||
"locale": "en_GB",
|
||||
"params": {
|
||||
"names": []
|
||||
}
|
||||
},
|
||||
"data": [
|
||||
[
|
||||
"Input Voltage",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
"V"
|
||||
],
|
||||
[
|
||||
"I/P Fault Voltage",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
"V"
|
||||
],
|
||||
[
|
||||
"Output Voltage",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
"V"
|
||||
],
|
||||
[
|
||||
"Load",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
"%"
|
||||
],
|
||||
[
|
||||
"Input Frequency",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
"Hz"
|
||||
],
|
||||
[
|
||||
"Battery Cell Voltage",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
"V"
|
||||
],
|
||||
[
|
||||
"Temperature",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
"℃"
|
||||
],
|
||||
[
|
||||
"Utility State",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
""
|
||||
],
|
||||
[
|
||||
"Battery Low Voltage",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
""
|
||||
],
|
||||
[
|
||||
"Bypass Mode",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
""
|
||||
],
|
||||
[
|
||||
"Fault State",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
""
|
||||
],
|
||||
[
|
||||
"UPS Type",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
""
|
||||
],
|
||||
[
|
||||
"Testing",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
""
|
||||
],
|
||||
[
|
||||
"Shutdown Active",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
""
|
||||
],
|
||||
[
|
||||
"Buzzer",
|
||||
"",
|
||||
"0",
|
||||
"0.0",
|
||||
""
|
||||
],
|
||||
[
|
||||
"Communication State",
|
||||
"2026.04.28 08:05:16",
|
||||
"0.0",
|
||||
"Lost",
|
||||
""
|
||||
]
|
||||
]
|
||||
}
|
||||
5
phoenix/.formatter.exs
Normal file
5
phoenix/.formatter.exs
Normal file
@@ -0,0 +1,5 @@
|
||||
[
|
||||
import_deps: [:ecto, :ecto_sql, :phoenix],
|
||||
subdirectories: ["priv/*/migrations"],
|
||||
inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"]
|
||||
]
|
||||
31
phoenix/.gitignore
vendored
Normal file
31
phoenix/.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
|
||||
# If you run "mix test --cover", coverage assets end up here.
|
||||
/cover/
|
||||
|
||||
# The directory Mix downloads your dependencies sources to.
|
||||
/deps/
|
||||
|
||||
# Where 3rd-party dependencies like ExDoc output generated docs.
|
||||
/doc/
|
||||
|
||||
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||
/.fetch
|
||||
|
||||
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||
erl_crash.dump
|
||||
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
|
||||
# Temporary files, for example, from tests.
|
||||
/tmp/
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
shooting_event_phx-*.tar
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.db-*
|
||||
|
||||
111
phoenix/AGENTS.md
Normal file
111
phoenix/AGENTS.md
Normal file
@@ -0,0 +1,111 @@
|
||||
This is a web application written using the Phoenix web framework.
|
||||
|
||||
## Project guidelines
|
||||
|
||||
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
|
||||
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
|
||||
|
||||
### Phoenix v1.8 guidelines
|
||||
|
||||
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
|
||||
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
|
||||
- Anytime you run into errors with no `current_scope` assign:
|
||||
- You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
|
||||
- **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
|
||||
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
|
||||
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
|
||||
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors
|
||||
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
|
||||
custom classes must fully style the input
|
||||
|
||||
|
||||
<!-- usage-rules-start -->
|
||||
|
||||
<!-- phoenix:elixir-start -->
|
||||
## Elixir guidelines
|
||||
|
||||
- Elixir lists **do not support index based access via the access syntax**
|
||||
|
||||
**Never do this (invalid)**:
|
||||
|
||||
i = 0
|
||||
mylist = ["blue", "green"]
|
||||
mylist[i]
|
||||
|
||||
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
|
||||
|
||||
i = 0
|
||||
mylist = ["blue", "green"]
|
||||
Enum.at(mylist, i)
|
||||
|
||||
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
|
||||
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
|
||||
|
||||
# INVALID: we are rebinding inside the `if` and the result never gets assigned
|
||||
if connected?(socket) do
|
||||
socket = assign(socket, :val, val)
|
||||
end
|
||||
|
||||
# VALID: we rebind the result of the `if` to a new variable
|
||||
socket =
|
||||
if connected?(socket) do
|
||||
assign(socket, :val, val)
|
||||
end
|
||||
|
||||
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
|
||||
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
|
||||
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
|
||||
- Don't use `String.to_atom/1` on user input (memory leak risk)
|
||||
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
|
||||
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
|
||||
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
|
||||
|
||||
## Mix guidelines
|
||||
|
||||
- Read the docs and options before using tasks (by using `mix help task_name`)
|
||||
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
|
||||
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
|
||||
|
||||
## Test guidelines
|
||||
|
||||
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
|
||||
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
|
||||
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
|
||||
|
||||
ref = Process.monitor(pid)
|
||||
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
|
||||
|
||||
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
|
||||
<!-- phoenix:elixir-end -->
|
||||
|
||||
<!-- phoenix:phoenix-start -->
|
||||
## Phoenix guidelines
|
||||
|
||||
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
|
||||
|
||||
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
|
||||
|
||||
scope "/admin", AppWeb.Admin do
|
||||
pipe_through :browser
|
||||
|
||||
live "/users", UserLive, :index
|
||||
end
|
||||
|
||||
the UserLive route would point to the `AppWeb.Admin.UserLive` module
|
||||
|
||||
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
|
||||
<!-- phoenix:phoenix-end -->
|
||||
|
||||
<!-- phoenix:ecto-start -->
|
||||
## Ecto Guidelines
|
||||
|
||||
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
|
||||
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
|
||||
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
|
||||
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
|
||||
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
|
||||
- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
|
||||
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
|
||||
<!-- phoenix:ecto-end -->
|
||||
|
||||
<!-- usage-rules-end -->
|
||||
51
phoenix/README.md
Normal file
51
phoenix/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Shooting Event Phoenix Backend
|
||||
|
||||
This folder contains a Phoenix + SQLite implementation of the existing Go API contract used by the Vue frontend.
|
||||
|
||||
## Run (dev)
|
||||
|
||||
```bash
|
||||
cd phoenix
|
||||
mix deps.get
|
||||
mix ecto.create
|
||||
mix ecto.migrate
|
||||
PORT=8080 mix phx.server
|
||||
```
|
||||
|
||||
Or from project root:
|
||||
|
||||
```bash
|
||||
make phoenix-dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make phoenix-build
|
||||
```
|
||||
|
||||
## API compatibility
|
||||
|
||||
The Phoenix app exposes the same `/api/*` endpoints currently used by the Vue app, including:
|
||||
|
||||
- Public:
|
||||
- `GET /api/health`
|
||||
- `GET /api/state`
|
||||
- `GET /api/events` (SSE)
|
||||
- Admin:
|
||||
- `POST /api/admin/login`
|
||||
- `POST /api/admin/logout`
|
||||
- `GET /api/admin/state`
|
||||
- `PUT /api/admin/settings`
|
||||
- Player CRUD + auto-group
|
||||
- Score/proof update/delete + reset
|
||||
- AI score advice endpoint
|
||||
|
||||
## Environment
|
||||
|
||||
- `PORT` (default: `8080`)
|
||||
- `DB_PATH` (default: `../data/shooting_phoenix.db`)
|
||||
- `ADMIN_USER` (default: `datwyler`)
|
||||
- `ADMIN_PASS` (default: `datwyler`)
|
||||
- `GEMINI_API_KEY` (default follows existing project fallback)
|
||||
- `GEMINI_MODEL` (default: `gemini-3.1-flash-lite-preview`)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user