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