diff --git a/Dockerfile.phoenix b/Dockerfile.phoenix
new file mode 100644
index 0000000..1f9b0ee
--- /dev/null
+++ b/Dockerfile.phoenix
@@ -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"]
diff --git a/Makefile b/Makefile
index 3571e9f..ca61f9a 100644
--- a/Makefile
+++ b/Makefile
@@ -4,15 +4,17 @@ PNPM ?= pnpm
GO ?= go
CONTAINER_REG ?= repo.ssp-itinfra.com/admin
IMAGE_NAME ?= shooting-event
+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 .
@@ -40,6 +42,12 @@ build: build-frontend
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 +60,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:
diff --git a/README.md b/README.md
index ea9982c..7aee4a9 100644
--- a/README.md
+++ b/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
diff --git a/backend/ai_handlers.go b/backend/ai_handlers.go
index 4d747e9..7fd7942 100644
--- a/backend/ai_handlers.go
+++ b/backend/ai_handlers.go
@@ -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."
diff --git a/backend/api_docs.go b/backend/api_docs.go
new file mode 100644
index 0000000..bf9bfe7
--- /dev/null
+++ b/backend/api_docs.go
@@ -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(`
+
+
+
+
+ Shooting Event API Docs
+
+
+
+
+
+ Shooting Event OpenAPI Docs
+
+ Use Try it out for live testing. For admin endpoints: login with the form below,
+ then your token is auto-attached to Swagger Authorize.
+
+
+
+
+
+
+
+
+
+
+`, "/api/openapi.json")
+
+ return c.HTML(http.StatusOK, html)
+}
diff --git a/backend/gemini.go b/backend/gemini.go
index caeaccb..1a9f65d 100644
--- a/backend/gemini.go
+++ b/backend/gemini.go
@@ -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":,"reason":""}
+{"advisedScore":,"reason":""}
Do not add markdown or extra fields.`, stage, currentScore)
}
diff --git a/backend/handlers.go b/backend/handlers.go
index 46bc026..443808c 100644
--- a/backend/handlers.go
+++ b/backend/handlers.go
@@ -310,8 +310,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(`
diff --git a/backend/openapi/openapi.json b/backend/openapi/openapi.json
new file mode 100644
index 0000000..2b224a5
--- /dev/null
+++ b/backend/openapi/openapi.json
@@ -0,0 +1,1408 @@
+{
+ "openapi": "3.0.3",
+ "info": {
+ "title": "Shooting Event API",
+ "description": "REST API for live scoring, admin control, proof images, and AI score advice.",
+ "version": "1.0.0"
+ },
+ "servers": [
+ {
+ "url": "/api",
+ "description": "Same host API base path"
+ }
+ ],
+ "tags": [
+ {
+ "name": "Public",
+ "description": "Public endpoints available without admin authentication"
+ },
+ {
+ "name": "Admin",
+ "description": "Admin endpoints protected by bearer token"
+ }
+ ],
+ "paths": {
+ "/health": {
+ "get": {
+ "tags": [
+ "Public"
+ ],
+ "summary": "Health check",
+ "operationId": "getHealth",
+ "responses": {
+ "200": {
+ "description": "Service is healthy",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HealthResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/state": {
+ "get": {
+ "tags": [
+ "Public"
+ ],
+ "summary": "Get application state",
+ "description": "Returns public state by default. If a valid admin bearer token is sent, `scoreProofs` is included.",
+ "operationId": "getState",
+ "security": [
+ {},
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Current application state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StateResponse"
+ }
+ }
+ }
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
+ "/events": {
+ "get": {
+ "tags": [
+ "Public"
+ ],
+ "summary": "Live update stream (SSE)",
+ "description": "Server-Sent Events stream. Emits `ready` and `state` events plus periodic keepalive pings.",
+ "operationId": "streamEvents",
+ "responses": {
+ "200": {
+ "description": "Event stream opened",
+ "content": {
+ "text/event-stream": {
+ "schema": {
+ "type": "string",
+ "example": "event: ready\\ndata: {\"ts\":\"2026-04-28T10:00:00Z\"}\\n\\n"
+ }
+ }
+ }
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
+ "/admin/login": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Admin login",
+ "description": "Authenticates admin credentials and returns a bearer token valid for 8 hours.",
+ "operationId": "adminLogin",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AdminLoginRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Login successful",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AdminLoginResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
+ "/admin/logout": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Admin logout",
+ "operationId": "adminLogout",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Token invalidated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OkResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ }
+ }
+ }
+ },
+ "/admin/state": {
+ "get": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Get full admin state",
+ "description": "Same shape as `/state`, always includes admin-visible fields such as `scoreProofs`.",
+ "operationId": "getAdminState",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Full admin state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StateResponse"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
+ "/admin/settings": {
+ "put": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Update app settings",
+ "operationId": "updateAdminSettings",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AdminSettingsUpdateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Updated state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StateResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
+ "/admin/players": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Create player",
+ "operationId": "createPlayer",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlayerCreateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Player created; full updated state returned",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StateResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
+ "/admin/players/auto-group": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Auto-assign player groups",
+ "description": "Randomly shuffles players and assigns group codes in round-robin order.",
+ "operationId": "autoGroupPlayers",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AutoGroupPlayersRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Updated state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StateResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
+ "/admin/players/{id}": {
+ "put": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Update player",
+ "operationId": "updatePlayer",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/PlayerIdParam"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PlayerUpdateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Updated state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StateResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Delete player",
+ "operationId": "deletePlayer",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/PlayerIdParam"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Updated state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StateResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "404": {
+ "$ref": "#/components/responses/NotFound"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
+ "/admin/scores/{stage}/{id}": {
+ "put": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Set a score",
+ "description": "Upserts score for a given stage/player. Score must be 0..100.",
+ "operationId": "updateScore",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/ScoreStageParam"
+ },
+ {
+ "$ref": "#/components/parameters/PlayerIdParam"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScoreUpdateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Updated state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StateResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
+ "/admin/scores/{stage}/{id}/proof": {
+ "put": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Upload score proof image",
+ "description": "Stores proof image data (typically a data URL string).",
+ "operationId": "updateScoreProof",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/ScoreStageParam"
+ },
+ {
+ "$ref": "#/components/parameters/PlayerIdParam"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScoreProofUpdateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Updated state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StateResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Delete score proof image",
+ "operationId": "deleteScoreProof",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/ScoreStageParam"
+ },
+ {
+ "$ref": "#/components/parameters/PlayerIdParam"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Updated state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StateResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
+ "/admin/scores/{stage}/{id}/advice": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Get AI score advice",
+ "description": "Uses configured Gemini model with uploaded proof image to propose a score and reason.",
+ "operationId": "getScoreAdvice",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/ScoreStageParam"
+ },
+ {
+ "$ref": "#/components/parameters/PlayerIdParam"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Advice generated",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ScoreAdviceResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "502": {
+ "description": "AI upstream returned an error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "503": {
+ "description": "AI service not configured",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ },
+ "/admin/scores/{stage}/reset": {
+ "post": {
+ "tags": [
+ "Admin"
+ ],
+ "summary": "Reset a stage family",
+ "description": "Resets scores to zero for one stage family or tie-break stage. Optionally removes proofs.",
+ "operationId": "resetStage",
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/ResetStageParam"
+ }
+ ],
+ "requestBody": {
+ "required": false,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ResetStageRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Updated state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/StateResponse"
+ }
+ }
+ }
+ },
+ "400": {
+ "$ref": "#/components/responses/BadRequest"
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "500": {
+ "$ref": "#/components/responses/InternalServerError"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "bearerAuth": {
+ "type": "http",
+ "scheme": "bearer",
+ "bearerFormat": "Token"
+ }
+ },
+ "parameters": {
+ "PlayerIdParam": {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "description": "Player ID",
+ "schema": {
+ "type": "integer",
+ "minimum": 1
+ }
+ },
+ "ScoreStageParam": {
+ "name": "stage",
+ "in": "path",
+ "required": true,
+ "description": "Score stage key",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "prelim_r1",
+ "prelim_r2",
+ "prelim_r3",
+ "final_r1",
+ "final_r2",
+ "prelim_tiebreak",
+ "final_tiebreak"
+ ]
+ }
+ },
+ "ResetStageParam": {
+ "name": "stage",
+ "in": "path",
+ "required": true,
+ "description": "Reset target",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "preliminary",
+ "final",
+ "prelim_tiebreak",
+ "final_tiebreak"
+ ]
+ }
+ }
+ },
+ "responses": {
+ "BadRequest": {
+ "description": "Invalid input",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "Unauthorized": {
+ "description": "Missing or invalid bearer token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "NotFound": {
+ "description": "Resource not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "InternalServerError": {
+ "description": "Internal server error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "schemas": {
+ "ErrorResponse": {
+ "type": "object",
+ "required": [
+ "message"
+ ],
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ }
+ },
+ "OkResponse": {
+ "type": "object",
+ "required": [
+ "ok"
+ ],
+ "properties": {
+ "ok": {
+ "type": "boolean"
+ }
+ }
+ },
+ "HealthResponse": {
+ "type": "object",
+ "required": [
+ "status"
+ ],
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "ok"
+ }
+ }
+ },
+ "AdminLoginRequest": {
+ "type": "object",
+ "required": [
+ "username",
+ "password"
+ ],
+ "properties": {
+ "username": {
+ "type": "string"
+ },
+ "password": {
+ "type": "string"
+ }
+ }
+ },
+ "AdminLoginResponse": {
+ "type": "object",
+ "required": [
+ "token",
+ "expiresAt",
+ "username"
+ ],
+ "properties": {
+ "token": {
+ "type": "string"
+ },
+ "expiresAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "username": {
+ "type": "string"
+ }
+ }
+ },
+ "CompetitionMeta": {
+ "type": "object",
+ "required": [
+ "titleAr",
+ "titleEn"
+ ],
+ "properties": {
+ "titleAr": {
+ "type": "string"
+ },
+ "titleEn": {
+ "type": "string"
+ }
+ }
+ },
+ "Player": {
+ "type": "object",
+ "required": [
+ "id",
+ "nameAr",
+ "nameEn",
+ "groupCode",
+ "imageData"
+ ],
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "nameAr": {
+ "type": "string"
+ },
+ "nameEn": {
+ "type": "string"
+ },
+ "groupCode": {
+ "type": "string"
+ },
+ "imageData": {
+ "type": "string"
+ }
+ }
+ },
+ "RankingRow": {
+ "type": "object",
+ "required": [
+ "playerId",
+ "nameAr",
+ "nameEn",
+ "groupCode",
+ "imageData",
+ "score",
+ "tieBreak",
+ "rank",
+ "seed",
+ "finalGroup"
+ ],
+ "properties": {
+ "playerId": {
+ "type": "integer"
+ },
+ "nameAr": {
+ "type": "string"
+ },
+ "nameEn": {
+ "type": "string"
+ },
+ "groupCode": {
+ "type": "string"
+ },
+ "imageData": {
+ "type": "string"
+ },
+ "score": {
+ "type": "integer"
+ },
+ "tieBreak": {
+ "type": "integer"
+ },
+ "rank": {
+ "type": "integer"
+ },
+ "seed": {
+ "type": "integer"
+ },
+ "finalGroup": {
+ "type": "integer"
+ }
+ }
+ },
+ "TieBreakInfo": {
+ "type": "object",
+ "required": [
+ "required",
+ "resolved",
+ "slots",
+ "playerIds"
+ ],
+ "properties": {
+ "required": {
+ "type": "boolean"
+ },
+ "resolved": {
+ "type": "boolean"
+ },
+ "slots": {
+ "type": "integer"
+ },
+ "playerIds": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ }
+ }
+ },
+ "RankingBundle": {
+ "type": "object",
+ "required": [
+ "rows",
+ "tieBreak",
+ "unresolved"
+ ],
+ "properties": {
+ "rows": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/RankingRow"
+ }
+ },
+ "tieBreak": {
+ "$ref": "#/components/schemas/TieBreakInfo"
+ },
+ "unresolved": {
+ "type": "boolean"
+ }
+ }
+ },
+ "FinalGroups": {
+ "type": "object",
+ "required": [
+ "group1",
+ "group2"
+ ],
+ "properties": {
+ "group1": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/RankingRow"
+ }
+ },
+ "group2": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/RankingRow"
+ }
+ }
+ }
+ },
+ "DerivedState": {
+ "type": "object",
+ "required": [
+ "preliminaryRanking",
+ "finalists",
+ "finalGroups",
+ "finalRanking",
+ "podium"
+ ],
+ "properties": {
+ "preliminaryRanking": {
+ "$ref": "#/components/schemas/RankingBundle"
+ },
+ "finalists": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/RankingRow"
+ }
+ },
+ "finalGroups": {
+ "$ref": "#/components/schemas/FinalGroups"
+ },
+ "finalRanking": {
+ "$ref": "#/components/schemas/RankingBundle"
+ },
+ "podium": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/RankingRow"
+ }
+ }
+ }
+ },
+ "LiveModeSettings": {
+ "type": "object",
+ "required": [
+ "activeView",
+ "showGroupLive",
+ "groupDisplayMode",
+ "groupFixedCode",
+ "showPrelimTie",
+ "showPrelimOverall",
+ "showFinalGroups",
+ "finalDisplayMode",
+ "finalFixedGroup",
+ "showFinalTie",
+ "showPodium",
+ "rotationIntervalSec",
+ "rotationPlayerCount"
+ ],
+ "properties": {
+ "activeView": {
+ "type": "string",
+ "enum": [
+ "group_live",
+ "prelim_tie",
+ "prelim_overall",
+ "final_groups",
+ "final_tie",
+ "podium"
+ ]
+ },
+ "showGroupLive": {
+ "type": "boolean"
+ },
+ "groupDisplayMode": {
+ "type": "string",
+ "enum": [
+ "rotate",
+ "fixed"
+ ]
+ },
+ "groupFixedCode": {
+ "type": "string"
+ },
+ "showPrelimTie": {
+ "type": "boolean"
+ },
+ "showPrelimOverall": {
+ "type": "boolean"
+ },
+ "showFinalGroups": {
+ "type": "boolean"
+ },
+ "finalDisplayMode": {
+ "type": "string",
+ "enum": [
+ "rotate",
+ "fixed"
+ ]
+ },
+ "finalFixedGroup": {
+ "type": "integer",
+ "enum": [
+ 1,
+ 2
+ ]
+ },
+ "showFinalTie": {
+ "type": "boolean"
+ },
+ "showPodium": {
+ "type": "boolean"
+ },
+ "rotationIntervalSec": {
+ "type": "integer",
+ "minimum": 3,
+ "maximum": 30
+ },
+ "rotationPlayerCount": {
+ "type": "integer",
+ "minimum": 3,
+ "maximum": 40
+ }
+ }
+ },
+ "LiveModeSettingsUpdateRequest": {
+ "type": "object",
+ "description": "Partial patch. Send only fields that need to change.",
+ "properties": {
+ "activeView": {
+ "type": "string",
+ "enum": [
+ "group_live",
+ "prelim_tie",
+ "prelim_overall",
+ "final_groups",
+ "final_tie",
+ "podium"
+ ]
+ },
+ "showGroupLive": {
+ "type": "boolean"
+ },
+ "groupDisplayMode": {
+ "type": "string",
+ "enum": [
+ "rotate",
+ "fixed"
+ ]
+ },
+ "groupFixedCode": {
+ "type": "string"
+ },
+ "showPrelimTie": {
+ "type": "boolean"
+ },
+ "showPrelimOverall": {
+ "type": "boolean"
+ },
+ "showFinalGroups": {
+ "type": "boolean"
+ },
+ "finalDisplayMode": {
+ "type": "string",
+ "enum": [
+ "rotate",
+ "fixed"
+ ]
+ },
+ "finalFixedGroup": {
+ "type": "integer",
+ "enum": [
+ 1,
+ 2
+ ]
+ },
+ "showFinalTie": {
+ "type": "boolean"
+ },
+ "showPodium": {
+ "type": "boolean"
+ },
+ "rotationIntervalSec": {
+ "type": "integer",
+ "minimum": 3,
+ "maximum": 30
+ },
+ "rotationPlayerCount": {
+ "type": "integer",
+ "minimum": 3,
+ "maximum": 40
+ }
+ }
+ },
+ "AppSettings": {
+ "type": "object",
+ "required": [
+ "viewProofInView",
+ "liveMode"
+ ],
+ "properties": {
+ "viewProofInView": {
+ "type": "boolean"
+ },
+ "liveMode": {
+ "$ref": "#/components/schemas/LiveModeSettings"
+ }
+ }
+ },
+ "StateResponse": {
+ "type": "object",
+ "required": [
+ "competition",
+ "players",
+ "scores",
+ "settings",
+ "derived",
+ "serverTime"
+ ],
+ "properties": {
+ "competition": {
+ "$ref": "#/components/schemas/CompetitionMeta"
+ },
+ "players": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Player"
+ }
+ },
+ "scores": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "integer"
+ }
+ }
+ },
+ "scoreProofs": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ },
+ "description": "Included for admin-authenticated state requests."
+ },
+ "settings": {
+ "$ref": "#/components/schemas/AppSettings"
+ },
+ "derived": {
+ "$ref": "#/components/schemas/DerivedState"
+ },
+ "serverTime": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "AdminSettingsUpdateRequest": {
+ "type": "object",
+ "description": "At least one field must be provided.",
+ "properties": {
+ "viewProofInView": {
+ "type": "boolean"
+ },
+ "liveMode": {
+ "$ref": "#/components/schemas/LiveModeSettingsUpdateRequest"
+ }
+ }
+ },
+ "PlayerCreateRequest": {
+ "type": "object",
+ "required": [
+ "nameAr",
+ "nameEn",
+ "groupCode"
+ ],
+ "properties": {
+ "nameAr": {
+ "type": "string"
+ },
+ "nameEn": {
+ "type": "string"
+ },
+ "groupCode": {
+ "type": "string"
+ }
+ }
+ },
+ "PlayerUpdateRequest": {
+ "type": "object",
+ "description": "Partial patch. Set `clearImage` true to remove image.",
+ "properties": {
+ "nameAr": {
+ "type": "string"
+ },
+ "nameEn": {
+ "type": "string"
+ },
+ "groupCode": {
+ "type": "string"
+ },
+ "imageData": {
+ "type": "string",
+ "description": "Base64/data URL payload. Max about 2.5MB characters."
+ },
+ "clearImage": {
+ "type": "boolean"
+ }
+ }
+ },
+ "AutoGroupPlayersRequest": {
+ "type": "object",
+ "required": [
+ "groups"
+ ],
+ "properties": {
+ "groups": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ }
+ }
+ },
+ "ScoreUpdateRequest": {
+ "type": "object",
+ "required": [
+ "score"
+ ],
+ "properties": {
+ "score": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 100
+ }
+ }
+ },
+ "ScoreProofUpdateRequest": {
+ "type": "object",
+ "required": [
+ "imageData"
+ ],
+ "properties": {
+ "imageData": {
+ "type": "string",
+ "description": "Base64/data URL payload. Max about 5MB characters."
+ }
+ }
+ },
+ "ResetStageRequest": {
+ "type": "object",
+ "properties": {
+ "resetProofs": {
+ "type": "boolean"
+ }
+ }
+ },
+ "ScoreAdviceResponse": {
+ "type": "object",
+ "required": [
+ "stage",
+ "playerId",
+ "advisedScore",
+ "reason",
+ "generatedAt"
+ ],
+ "properties": {
+ "stage": {
+ "type": "string"
+ },
+ "playerId": {
+ "type": "integer"
+ },
+ "advisedScore": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 100
+ },
+ "reason": {
+ "type": "string"
+ },
+ "generatedAt": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/backend/routes.go b/backend/routes.go
index 43ab6f7..22d77de 100644
--- a/backend/routes.go
+++ b/backend/routes.go
@@ -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)
diff --git a/frontend/public/arture_logo.png b/frontend/public/arture_logo.png
new file mode 100644
index 0000000..98a6c0e
Binary files /dev/null and b/frontend/public/arture_logo.png differ
diff --git a/frontend/public/datwyler_logo.png b/frontend/public/datwyler_logo.png
new file mode 100644
index 0000000..2ade705
Binary files /dev/null and b/frontend/public/datwyler_logo.png differ
diff --git a/frontend/public/simon_logo.png b/frontend/public/simon_logo.png
new file mode 100644
index 0000000..9ecf012
Binary files /dev/null and b/frontend/public/simon_logo.png differ
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index f747003..08bf3d3 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -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"
/>
@@ -133,6 +135,7 @@
: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"
@@ -222,6 +225,7 @@ 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))
@@ -640,6 +644,11 @@ watch(
)
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 +656,10 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
+ document.removeEventListener('fullscreenchange', onFullscreenChange)
+ document.removeEventListener('webkitfullscreenchange', onFullscreenChange)
+ document.removeEventListener('mozfullscreenchange', onFullscreenChange)
+ document.removeEventListener('MSFullscreenChange', onFullscreenChange)
stopLiveRotation()
stopLiveMonitorRotation()
stopRealtimeSync()
@@ -662,6 +675,57 @@ 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'])
@@ -1209,7 +1273,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 +1351,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 +1400,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 +1414,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
}
diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue
index dbe1bb5..52ecb4b 100644
--- a/frontend/src/components/AdminPanel.vue
+++ b/frontend/src/components/AdminPanel.vue
@@ -98,6 +98,7 @@
: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"
@@ -139,6 +140,7 @@
: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"
@@ -188,6 +190,7 @@
: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"
@@ -224,6 +227,7 @@
: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"
@@ -313,6 +317,7 @@ defineProps({
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 },
diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue
index 270a56b..5c555a3 100644
--- a/frontend/src/components/AppHeader.vue
+++ b/frontend/src/components/AppHeader.vue
@@ -16,6 +16,35 @@
{{ optionsLabel }}
{{ modeShort }} · {{ languageShort }}
+
@@ -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
diff --git a/frontend/src/components/LiveModePanel.vue b/frontend/src/components/LiveModePanel.vue
index 4fa5d88..2fbe4ae 100644
--- a/frontend/src/components/LiveModePanel.vue
+++ b/frontend/src/components/LiveModePanel.vue
@@ -1,14 +1,15 @@
-
-
+
+
+
-
+
@@ -256,7 +257,7 @@
🥈
-
{{ podiumName(podiumOrdered[0]) }}
+
{{ podiumName(podiumOrdered[0]) }}
{{ podiumScoreMain(podiumOrdered[0]) }}
{{ podiumTieSubtitle(podiumOrdered[0]) }}
@@ -268,7 +269,7 @@
🥇
-
{{ podiumName(podiumOrdered[1]) }}
+
{{ podiumName(podiumOrdered[1]) }}
{{ podiumScoreMain(podiumOrdered[1]) }}
{{ podiumTieSubtitle(podiumOrdered[1]) }}
@@ -280,7 +281,7 @@
🥉
-
{{ podiumName(podiumOrdered[2]) }}
+
{{ podiumName(podiumOrdered[2]) }}
{{ podiumScoreMain(podiumOrdered[2]) }}
{{ podiumTieSubtitle(podiumOrdered[2]) }}
@@ -289,8 +290,22 @@
-
-
+
+
+
+
+