api specs
This commit is contained in:
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"]
|
||||||
23
Makefile
23
Makefile
@@ -4,15 +4,17 @@ PNPM ?= pnpm
|
|||||||
GO ?= go
|
GO ?= go
|
||||||
CONTAINER_REG ?= repo.ssp-itinfra.com/admin
|
CONTAINER_REG ?= repo.ssp-itinfra.com/admin
|
||||||
IMAGE_NAME ?= shooting-event
|
IMAGE_NAME ?= shooting-event
|
||||||
|
PHOENIX_IMAGE_NAME ?= $(IMAGE_NAME)-phoenix
|
||||||
ARCH ?= amd64
|
ARCH ?= amd64
|
||||||
BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
BUILD_VERSION ?= $(shell git describe --tags --always --dirty)
|
BUILD_VERSION ?= $(shell git describe --tags --always --dirty)
|
||||||
|
|
||||||
.PHONY: install dev dev-backend dev-frontend build build-frontend build-backend docker-build docker-run clean
|
.PHONY: install dev dev-backend dev-frontend build build-frontend build-backend phoenix-dev phoenix-build docker-build docker-run docker-build-phoenix docker-run-phoenix clean
|
||||||
|
|
||||||
install:
|
install:
|
||||||
cd frontend && $(PNPM) install
|
cd frontend && $(PNPM) install
|
||||||
cd backend && $(GO) mod tidy
|
cd backend && $(GO) mod tidy
|
||||||
|
cd phoenix && mix deps.get
|
||||||
|
|
||||||
dev-backend:
|
dev-backend:
|
||||||
cd backend && $(GO) run .
|
cd backend && $(GO) run .
|
||||||
@@ -40,6 +42,12 @@ build: build-frontend
|
|||||||
cp -R frontend/dist/. backend/web/
|
cp -R frontend/dist/. backend/web/
|
||||||
$(MAKE) build-backend
|
$(MAKE) build-backend
|
||||||
|
|
||||||
|
phoenix-dev:
|
||||||
|
cd phoenix && mix deps.get && mix ecto.create && mix ecto.migrate && PORT=$${PORT:-8080} mix phx.server
|
||||||
|
|
||||||
|
phoenix-build:
|
||||||
|
cd phoenix && MIX_ENV=prod mix deps.get --only prod && MIX_ENV=prod mix compile
|
||||||
|
|
||||||
docker-build:
|
docker-build:
|
||||||
docker buildx build --load --platform=linux/$(ARCH) -t $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) .
|
docker buildx build --load --platform=linux/$(ARCH) -t $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) .
|
||||||
docker tag $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-latest
|
docker tag $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-latest
|
||||||
@@ -52,7 +60,20 @@ docker-run:
|
|||||||
mkdir -p data
|
mkdir -p data
|
||||||
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)
|
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)
|
||||||
|
|
||||||
|
docker-build-phoenix:
|
||||||
|
docker buildx build --load --platform=linux/$(ARCH) -f Dockerfile.phoenix -t $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) .
|
||||||
|
docker tag $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-latest
|
||||||
|
docker tag $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
|
||||||
|
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)
|
||||||
|
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-latest
|
||||||
|
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
|
||||||
|
|
||||||
|
docker-run-phoenix:
|
||||||
|
mkdir -p data
|
||||||
|
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)
|
||||||
|
|
||||||
release:
|
release:
|
||||||
|
${MAKE} build
|
||||||
$(MAKE) docker-build
|
$(MAKE) docker-build
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -34,29 +34,39 @@ Production-ready full-stack web app based on your original live score concept.
|
|||||||
8. Podium is determined automatically.
|
8. Podium is determined automatically.
|
||||||
9. If top-3 tie exists, podium tie-break stage appears.
|
9. If top-3 tie exists, podium tie-break stage appears.
|
||||||
|
|
||||||
## API Highlights
|
## API Documentation (OpenAPI)
|
||||||
|
|
||||||
Public:
|
Interactive docs are now built in:
|
||||||
|
|
||||||
- `GET /api/health`
|
- Swagger UI: `GET /api/docs`
|
||||||
- `GET /api/state`
|
- OpenAPI JSON: `GET /api/openapi.json`
|
||||||
|
|
||||||
Admin:
|
### Testing with Auth in Swagger UI
|
||||||
|
|
||||||
- `POST /api/admin/login`
|
1. Open `/api/docs`.
|
||||||
- `POST /api/admin/logout`
|
2. Use the **Login & Authorize** form at the top:
|
||||||
- `POST /api/admin/players`
|
- Username: `datwyler` (default)
|
||||||
- `PUT /api/admin/players/:id`
|
- Password: `datwyler` (default)
|
||||||
- `DELETE /api/admin/players/:id`
|
3. The page calls `POST /api/admin/login`, retrieves the token, and auto-authorizes Swagger.
|
||||||
- `PUT /api/admin/scores/:stage/:id`
|
4. Use **Try it out** on any admin endpoint.
|
||||||
- `POST /api/admin/scores/:stage/:id/advice`
|
|
||||||
- `POST /api/admin/scores/:stage/reset`
|
|
||||||
|
|
||||||
Stages:
|
### Stage Values
|
||||||
|
|
||||||
|
For score update/proof/advice endpoints (`/api/admin/scores/{stage}/...`), use:
|
||||||
|
|
||||||
|
- `prelim_r1`
|
||||||
|
- `prelim_r2`
|
||||||
|
- `prelim_r3`
|
||||||
|
- `final_r1`
|
||||||
|
- `final_r2`
|
||||||
|
- `prelim_tiebreak`
|
||||||
|
- `final_tiebreak`
|
||||||
|
|
||||||
|
For reset endpoint (`POST /api/admin/scores/{stage}/reset`), use:
|
||||||
|
|
||||||
- `preliminary`
|
- `preliminary`
|
||||||
- `prelim_tiebreak`
|
|
||||||
- `final`
|
- `final`
|
||||||
|
- `prelim_tiebreak`
|
||||||
- `final_tiebreak`
|
- `final_tiebreak`
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func (a *App) loadCurrentScore(stage string, playerID int) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) buildAdviceResponse(stage string, playerID int, raw scoreAdviceModelResponse) ScoreAdviceResponse {
|
func (a *App) buildAdviceResponse(stage string, playerID int, raw scoreAdviceModelResponse) ScoreAdviceResponse {
|
||||||
advised := clampInt(raw.AdvisedScore, 0, 9999)
|
advised := clampInt(raw.AdvisedScore, 0, 100)
|
||||||
reason := strings.TrimSpace(raw.Reason)
|
reason := strings.TrimSpace(raw.Reason)
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
reason = "AI estimated the score from visible impacts."
|
reason = "AI estimated the score from visible impacts."
|
||||||
|
|||||||
145
backend/api_docs.go
Normal file
145
backend/api_docs.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed openapi/openapi.json
|
||||||
|
var openAPISpecJSON string
|
||||||
|
|
||||||
|
func (a *App) handleOpenAPISpec(c echo.Context) error {
|
||||||
|
return c.Blob(http.StatusOK, "application/json; charset=utf-8", []byte(openAPISpecJSON))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleOpenAPIDocs(c echo.Context) error {
|
||||||
|
html := fmt.Sprintf(`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Shooting Event API Docs</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
.docs-top {
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #dde5f3;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.docs-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #1b1f40;
|
||||||
|
}
|
||||||
|
.docs-help {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4c5773;
|
||||||
|
}
|
||||||
|
.auth-panel {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.auth-panel input {
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid #cdd7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.auth-panel button {
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid #20284f;
|
||||||
|
background: #20284f;
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.auth-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #38446b;
|
||||||
|
}
|
||||||
|
#swagger-ui {
|
||||||
|
max-width: 1300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section class="docs-top">
|
||||||
|
<h1 class="docs-title">Shooting Event OpenAPI Docs</h1>
|
||||||
|
<p class="docs-help">
|
||||||
|
Use <strong>Try it out</strong> for live testing. For admin endpoints: login with the form below,
|
||||||
|
then your token is auto-attached to Swagger Authorize.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="auth-form" class="auth-panel">
|
||||||
|
<input id="admin-user" type="text" value="datwyler" autocomplete="username" placeholder="Admin username" />
|
||||||
|
<input id="admin-pass" type="password" value="datwyler" autocomplete="current-password" placeholder="Admin password" />
|
||||||
|
<button type="submit">Login & Authorize</button>
|
||||||
|
<span id="auth-status" class="auth-status"></span>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script>
|
||||||
|
const ui = SwaggerUIBundle({
|
||||||
|
url: %q,
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
displayRequestDuration: true,
|
||||||
|
persistAuthorization: true,
|
||||||
|
docExpansion: 'list',
|
||||||
|
tryItOutEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loginAndAuthorize(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const status = document.getElementById('auth-status');
|
||||||
|
status.textContent = 'Logging in...';
|
||||||
|
|
||||||
|
const username = document.getElementById('admin-user').value || '';
|
||||||
|
const password = document.getElementById('admin-pass').value || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload.message || 'Login failed');
|
||||||
|
}
|
||||||
|
if (!payload.token) {
|
||||||
|
throw new Error('Login succeeded but token was not returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.preauthorizeApiKey('bearerAuth', payload.token);
|
||||||
|
status.textContent = 'Authorized until ' + (payload.expiresAt || 'token expiry');
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = 'Auth failed: ' + error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('auth-form').addEventListener('submit', loginAndAuthorize);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`, "/api/openapi.json")
|
||||||
|
|
||||||
|
return c.HTML(http.StatusOK, html)
|
||||||
|
}
|
||||||
@@ -198,6 +198,6 @@ func scoreAdvicePrompt(stage string, currentScore int) string {
|
|||||||
return fmt.Sprintf(`Target scoring assistant.
|
return fmt.Sprintf(`Target scoring assistant.
|
||||||
Stage: %s. Current score: %d.
|
Stage: %s. Current score: %d.
|
||||||
Return STRICT JSON only:
|
Return STRICT JSON only:
|
||||||
{"advisedScore":<int 0..9999>,"reason":"<max 18 words>"}
|
{"advisedScore":<int 0..100>,"reason":"<max 18 words>"}
|
||||||
Do not add markdown or extra fields.`, stage, currentScore)
|
Do not add markdown or extra fields.`, stage, currentScore)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,8 +310,8 @@ func (a *App) handleUpdateScore(c echo.Context) error {
|
|||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
return writeError(c, http.StatusBadRequest, "invalid request body")
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||||
}
|
}
|
||||||
if req.Score < 0 || req.Score > 9999 {
|
if req.Score < 0 || req.Score > 100 {
|
||||||
return writeError(c, http.StatusBadRequest, "score must be between 0 and 9999")
|
return writeError(c, http.StatusBadRequest, "score must be between 0 and 100")
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := a.db.Exec(`
|
res, err := a.db.Exec(`
|
||||||
|
|||||||
1408
backend/openapi/openapi.json
Normal file
1408
backend/openapi/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,10 @@ import (
|
|||||||
|
|
||||||
func registerAPIRoutes(e *echo.Echo, app *App) {
|
func registerAPIRoutes(e *echo.Echo, app *App) {
|
||||||
api := e.Group("/api")
|
api := e.Group("/api")
|
||||||
|
api.GET("/docs", app.handleOpenAPIDocs)
|
||||||
|
api.GET("/docs/", app.handleOpenAPIDocs)
|
||||||
|
api.GET("/openapi.json", app.handleOpenAPISpec)
|
||||||
|
|
||||||
api.GET("/health", app.handleHealth)
|
api.GET("/health", app.handleHealth)
|
||||||
api.GET("/state", app.handleGetState)
|
api.GET("/state", app.handleGetState)
|
||||||
api.GET("/events", app.handleEvents)
|
api.GET("/events", app.handleEvents)
|
||||||
|
|||||||
BIN
frontend/public/arture_logo.png
Normal file
BIN
frontend/public/arture_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 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/simon_logo.png
Normal file
BIN
frontend/public/simon_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
@@ -8,9 +8,11 @@
|
|||||||
:competition-title="competitionTitle"
|
:competition-title="competitionTitle"
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
:language="language"
|
:language="language"
|
||||||
|
:is-fullscreen="isFullscreen"
|
||||||
:server-time="state.serverTime ? formatServerTime(state.serverTime) : ''"
|
:server-time="state.serverTime ? formatServerTime(state.serverTime) : ''"
|
||||||
@change-mode="mode = $event"
|
@change-mode="mode = $event"
|
||||||
@change-language="setLanguage"
|
@change-language="setLanguage"
|
||||||
|
@toggle-fullscreen="toggleFullscreen"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main class="page-content">
|
<main class="page-content">
|
||||||
@@ -133,6 +135,7 @@
|
|||||||
:display-name="displayName"
|
:display-name="displayName"
|
||||||
:secondary-name="secondaryName"
|
:secondary-name="secondaryName"
|
||||||
:score-input-value="scoreInputValue"
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
:on-score-focus="onScoreFocus"
|
:on-score-focus="onScoreFocus"
|
||||||
:on-score-input="onScoreInput"
|
:on-score-input="onScoreInput"
|
||||||
:on-score-commit="onScoreCommit"
|
:on-score-commit="onScoreCommit"
|
||||||
@@ -222,6 +225,7 @@ const mode = ref('live')
|
|||||||
const viewTab = ref('groups')
|
const viewTab = ref('groups')
|
||||||
const adminTab = ref('players')
|
const adminTab = ref('players')
|
||||||
const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar')
|
const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar')
|
||||||
|
const isFullscreen = ref(false)
|
||||||
const state = ref(createInitialState())
|
const state = ref(createInitialState())
|
||||||
const primaryGroups = ref(loadPrimaryGroups(localStorage))
|
const primaryGroups = ref(loadPrimaryGroups(localStorage))
|
||||||
|
|
||||||
@@ -640,6 +644,11 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
onFullscreenChange()
|
||||||
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
document.addEventListener('webkitfullscreenchange', onFullscreenChange)
|
||||||
|
document.addEventListener('mozfullscreenchange', onFullscreenChange)
|
||||||
|
document.addEventListener('MSFullscreenChange', onFullscreenChange)
|
||||||
document.documentElement.lang = language.value
|
document.documentElement.lang = language.value
|
||||||
document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr'
|
document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr'
|
||||||
await fetchState()
|
await fetchState()
|
||||||
@@ -647,6 +656,10 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
document.removeEventListener('webkitfullscreenchange', onFullscreenChange)
|
||||||
|
document.removeEventListener('mozfullscreenchange', onFullscreenChange)
|
||||||
|
document.removeEventListener('MSFullscreenChange', onFullscreenChange)
|
||||||
stopLiveRotation()
|
stopLiveRotation()
|
||||||
stopLiveMonitorRotation()
|
stopLiveMonitorRotation()
|
||||||
stopRealtimeSync()
|
stopRealtimeSync()
|
||||||
@@ -662,6 +675,57 @@ function setLanguage(next) {
|
|||||||
if (next === 'ar' || next === 'en') language.value = next
|
if (next === 'ar' || next === 'en') language.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFullscreenElement() {
|
||||||
|
return (
|
||||||
|
document.fullscreenElement ||
|
||||||
|
document.webkitFullscreenElement ||
|
||||||
|
document.mozFullScreenElement ||
|
||||||
|
document.msFullscreenElement ||
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFullscreenChange() {
|
||||||
|
isFullscreen.value = Boolean(getFullscreenElement())
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestFullscreenFor(element) {
|
||||||
|
if (!element) return Promise.resolve()
|
||||||
|
const fn =
|
||||||
|
element.requestFullscreen ||
|
||||||
|
element.webkitRequestFullscreen ||
|
||||||
|
element.mozRequestFullScreen ||
|
||||||
|
element.msRequestFullscreen
|
||||||
|
if (!fn) return Promise.resolve()
|
||||||
|
const result = fn.call(element)
|
||||||
|
if (result && typeof result.then === 'function') return result
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitFullscreen() {
|
||||||
|
const fn =
|
||||||
|
document.exitFullscreen ||
|
||||||
|
document.webkitExitFullscreen ||
|
||||||
|
document.mozCancelFullScreen ||
|
||||||
|
document.msExitFullscreen
|
||||||
|
if (!fn) return Promise.resolve()
|
||||||
|
const result = fn.call(document)
|
||||||
|
if (result && typeof result.then === 'function') return result
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
try {
|
||||||
|
if (getFullscreenElement()) {
|
||||||
|
await exitFullscreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await requestFullscreenFor(document.documentElement)
|
||||||
|
} catch {
|
||||||
|
// Ignore fullscreen errors, mostly caused by browser policy.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLiveActiveView(value) {
|
function normalizeLiveActiveView(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase()
|
const normalized = String(value || '').trim().toLowerCase()
|
||||||
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'podium'])
|
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'podium'])
|
||||||
@@ -1209,7 +1273,7 @@ async function applySuggestedScore() {
|
|||||||
if (!scoreAdviceModal.advice) return
|
if (!scoreAdviceModal.advice) return
|
||||||
const stage = scoreAdviceModal.stage
|
const stage = scoreAdviceModal.stage
|
||||||
const playerId = scoreAdviceModal.playerId
|
const playerId = scoreAdviceModal.playerId
|
||||||
const score = Number(scoreAdviceModal.advice.advisedScore || 0)
|
const score = clampScoreValue(scoreAdviceModal.advice.advisedScore)
|
||||||
await updateScoreValue(stage, playerId, score)
|
await updateScoreValue(stage, playerId, score)
|
||||||
scoreAdviceModal.currentScore = score
|
scoreAdviceModal.currentScore = score
|
||||||
closeScoreAdviceModal()
|
closeScoreAdviceModal()
|
||||||
@@ -1287,26 +1351,48 @@ function scoreDraftKey(stage, playerId) {
|
|||||||
return `${stage}:${playerId}`
|
return `${stage}:${playerId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampScoreValue(value) {
|
||||||
|
let score = Number(value)
|
||||||
|
if (!Number.isFinite(score)) score = 0
|
||||||
|
score = Math.trunc(score)
|
||||||
|
if (score < 0) return 0
|
||||||
|
if (score > 100) return 100
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
function scoreInputValue(stage, playerId) {
|
function scoreInputValue(stage, playerId) {
|
||||||
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
|
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
|
||||||
if (draft) return draft.value
|
if (draft) return draft.value
|
||||||
return String(scoreFor(stage, playerId))
|
return String(scoreFor(stage, playerId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scoreInputInvalid(stage, playerId) {
|
||||||
|
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
|
||||||
|
return Boolean(draft?.invalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDraftScoreValue(value) {
|
||||||
|
let score = Number(value === '' ? 0 : value)
|
||||||
|
if (!Number.isFinite(score)) score = 0
|
||||||
|
return Math.trunc(score)
|
||||||
|
}
|
||||||
|
|
||||||
function onScoreFocus(stage, playerId) {
|
function onScoreFocus(stage, playerId) {
|
||||||
const key = scoreDraftKey(stage, playerId)
|
const key = scoreDraftKey(stage, playerId)
|
||||||
if (!scoreDrafts[key]) {
|
if (!scoreDrafts[key]) {
|
||||||
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false }
|
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onScoreInput(stage, playerId, event) {
|
function onScoreInput(stage, playerId, event) {
|
||||||
const key = scoreDraftKey(stage, playerId)
|
const key = scoreDraftKey(stage, playerId)
|
||||||
const raw = String(event?.target?.value ?? '')
|
const raw = String(event?.target?.value ?? '')
|
||||||
const cleaned = raw.replace(/[^\d]/g, '').slice(0, 4)
|
const digits = raw.replace(/[^\d]/g, '').slice(0, 3)
|
||||||
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false }
|
const parsed = parseDraftScoreValue(digits)
|
||||||
scoreDrafts[key] = { ...current, value: cleaned }
|
const invalid = digits !== '' && (parsed < 0 || parsed > 100)
|
||||||
if (event?.target && event.target.value !== cleaned) event.target.value = cleaned
|
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
|
||||||
|
scoreDrafts[key] = { ...current, value: digits, invalid }
|
||||||
|
if (event?.target && event.target.value !== digits) event.target.value = digits
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onScoreCommit(stage, playerId) {
|
async function onScoreCommit(stage, playerId) {
|
||||||
@@ -1314,11 +1400,11 @@ async function onScoreCommit(stage, playerId) {
|
|||||||
const draft = scoreDrafts[key]
|
const draft = scoreDrafts[key]
|
||||||
if (!draft || draft.committing) return
|
if (!draft || draft.committing) return
|
||||||
|
|
||||||
let score = Number(draft.value === '' ? 0 : draft.value)
|
const score = parseDraftScoreValue(draft.value)
|
||||||
if (!Number.isFinite(score)) score = 0
|
const invalid = score < 0 || score > 100
|
||||||
score = Math.trunc(score)
|
|
||||||
|
|
||||||
if (score < 0 || score > 9999) {
|
if (invalid) {
|
||||||
|
scoreDrafts[key] = { ...draft, invalid: true }
|
||||||
showToast(t('messages.invalidScore'), 'error')
|
showToast(t('messages.invalidScore'), 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1328,12 +1414,12 @@ async function onScoreCommit(stage, playerId) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scoreDrafts[key] = { ...draft, committing: true }
|
scoreDrafts[key] = { ...draft, invalid: false, committing: true }
|
||||||
await updateScoreValue(stage, playerId, score, key)
|
await updateScoreValue(stage, playerId, score, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateScoreValue(stage, playerId, score, draftKey = '') {
|
async function updateScoreValue(stage, playerId, score, draftKey = '') {
|
||||||
if (score < 0 || score > 9999) {
|
if (score < 0 || score > 100) {
|
||||||
showToast(t('messages.invalidScore'), 'error')
|
showToast(t('messages.invalidScore'), 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
:display-name="displayName"
|
:display-name="displayName"
|
||||||
:secondary-name="secondaryName"
|
:secondary-name="secondaryName"
|
||||||
:score-input-value="scoreInputValue"
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
:on-score-focus="onScoreFocus"
|
:on-score-focus="onScoreFocus"
|
||||||
:on-score-input="onScoreInput"
|
:on-score-input="onScoreInput"
|
||||||
:on-score-commit="onScoreCommit"
|
:on-score-commit="onScoreCommit"
|
||||||
@@ -139,6 +140,7 @@
|
|||||||
:display-name="displayName"
|
:display-name="displayName"
|
||||||
:secondary-name="secondaryName"
|
:secondary-name="secondaryName"
|
||||||
:score-input-value="scoreInputValue"
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
:on-score-focus="onScoreFocus"
|
:on-score-focus="onScoreFocus"
|
||||||
:on-score-input="onScoreInput"
|
:on-score-input="onScoreInput"
|
||||||
:on-score-commit="onScoreCommit"
|
:on-score-commit="onScoreCommit"
|
||||||
@@ -188,6 +190,7 @@
|
|||||||
:display-name="displayName"
|
:display-name="displayName"
|
||||||
:secondary-name="secondaryName"
|
:secondary-name="secondaryName"
|
||||||
:score-input-value="scoreInputValue"
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
:on-score-focus="onScoreFocus"
|
:on-score-focus="onScoreFocus"
|
||||||
:on-score-input="onScoreInput"
|
:on-score-input="onScoreInput"
|
||||||
:on-score-commit="onScoreCommit"
|
:on-score-commit="onScoreCommit"
|
||||||
@@ -224,6 +227,7 @@
|
|||||||
:display-name="displayName"
|
:display-name="displayName"
|
||||||
:secondary-name="secondaryName"
|
:secondary-name="secondaryName"
|
||||||
:score-input-value="scoreInputValue"
|
:score-input-value="scoreInputValue"
|
||||||
|
:score-input-invalid="scoreInputInvalid"
|
||||||
:on-score-focus="onScoreFocus"
|
:on-score-focus="onScoreFocus"
|
||||||
:on-score-input="onScoreInput"
|
:on-score-input="onScoreInput"
|
||||||
:on-score-commit="onScoreCommit"
|
:on-score-commit="onScoreCommit"
|
||||||
@@ -313,6 +317,7 @@ defineProps({
|
|||||||
displayName: { type: Function, required: true },
|
displayName: { type: Function, required: true },
|
||||||
secondaryName: { type: Function, required: true },
|
secondaryName: { type: Function, required: true },
|
||||||
scoreInputValue: { type: Function, required: true },
|
scoreInputValue: { type: Function, required: true },
|
||||||
|
scoreInputInvalid: { type: Function, required: true },
|
||||||
onScoreFocus: { type: Function, required: true },
|
onScoreFocus: { type: Function, required: true },
|
||||||
onScoreInput: { type: Function, required: true },
|
onScoreInput: { type: Function, required: true },
|
||||||
onScoreCommit: { type: Function, required: true },
|
onScoreCommit: { type: Function, required: true },
|
||||||
|
|||||||
@@ -16,6 +16,35 @@
|
|||||||
<span>{{ optionsLabel }}</span>
|
<span>{{ optionsLabel }}</span>
|
||||||
<span class="control-toggle-meta">{{ modeShort }} · {{ languageShort }}</span>
|
<span class="control-toggle-meta">{{ modeShort }} · {{ languageShort }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="control-icon-btn"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: isFullscreen }"
|
||||||
|
:aria-label="fullscreenActionLabel"
|
||||||
|
:title="fullscreenActionLabel"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
v-if="!isFullscreen"
|
||||||
|
d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
v-else
|
||||||
|
d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition name="controls-reveal">
|
<Transition name="controls-reveal">
|
||||||
@@ -57,9 +86,10 @@ const props = defineProps({
|
|||||||
mode: { type: String, required: true },
|
mode: { type: String, required: true },
|
||||||
language: { type: String, required: true },
|
language: { type: String, required: true },
|
||||||
serverTime: { type: String, default: '' },
|
serverTime: { type: String, default: '' },
|
||||||
|
isFullscreen: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['change-mode', 'change-language'])
|
const emit = defineEmits(['change-mode', 'change-language', 'toggle-fullscreen'])
|
||||||
|
|
||||||
const controlsOpen = ref(false)
|
const controlsOpen = ref(false)
|
||||||
const controlsRoot = ref(null)
|
const controlsRoot = ref(null)
|
||||||
@@ -71,6 +101,7 @@ const modeShort = computed(() => {
|
|||||||
return props.language === 'ar' ? 'إدارة' : 'Admin'
|
return props.language === 'ar' ? 'إدارة' : 'Admin'
|
||||||
})
|
})
|
||||||
const languageShort = computed(() => (props.language === 'ar' ? 'AR' : 'EN'))
|
const languageShort = computed(() => (props.language === 'ar' ? 'AR' : 'EN'))
|
||||||
|
const fullscreenActionLabel = computed(() => (props.isFullscreen ? props.t('actions.exitFullscreen') : props.t('actions.enterFullscreen')))
|
||||||
const modeStatusLabel = computed(() => {
|
const modeStatusLabel = computed(() => {
|
||||||
if (props.mode === 'view') return '● ' + props.t('labels.liveTracker')
|
if (props.mode === 'view') return '● ' + props.t('labels.liveTracker')
|
||||||
if (props.mode === 'live') return '● ' + props.t('liveMode')
|
if (props.mode === 'live') return '● ' + props.t('liveMode')
|
||||||
@@ -91,6 +122,10 @@ function changeLanguage(nextLanguage) {
|
|||||||
controlsOpen.value = false
|
controlsOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleFullscreen() {
|
||||||
|
emit('toggle-fullscreen')
|
||||||
|
}
|
||||||
|
|
||||||
function onGlobalPointerDown(event) {
|
function onGlobalPointerDown(event) {
|
||||||
if (!controlsOpen.value) return
|
if (!controlsOpen.value) return
|
||||||
const root = controlsRoot.value
|
const root = controlsRoot.value
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="live-mode-shell">
|
<div class="live-mode-root">
|
||||||
<div class="live-mode-overlay">
|
<section class="live-mode-shell">
|
||||||
|
<div class="live-mode-overlay">
|
||||||
<header class="live-mode-header">
|
<header class="live-mode-header">
|
||||||
<h2>{{ t('sections.liveModeTitle') }}</h2>
|
<h2>{{ t('sections.liveModeTitle') }}</h2>
|
||||||
<p>{{ t('sections.liveModeSubtitle') }}</p>
|
<p>{{ t('sections.liveModeSubtitle') }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="live-screen-head live-head-selection">
|
<!-- <div class="live-screen-head live-head-selection">
|
||||||
<span class="live-chip strong">{{ activeViewLabel }}</span>
|
<span class="live-chip strong">{{ activeViewLabel }}</span>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<div class="live-mode-grid single">
|
<div class="live-mode-grid single">
|
||||||
<article v-if="activeView === 'group_live'" class="live-screen-card wide">
|
<article v-if="activeView === 'group_live'" class="live-screen-card wide">
|
||||||
@@ -256,7 +257,7 @@
|
|||||||
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
|
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
|
||||||
</div>
|
</div>
|
||||||
<div class="podium-medal-icon">🥈</div>
|
<div class="podium-medal-icon">🥈</div>
|
||||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
|
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[0])" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
|
||||||
<div class="podium-score-wrap">
|
<div class="podium-score-wrap">
|
||||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
|
||||||
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
|
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
|
||||||
@@ -268,7 +269,7 @@
|
|||||||
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
|
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
|
||||||
</div>
|
</div>
|
||||||
<div class="podium-medal-icon">🥇</div>
|
<div class="podium-medal-icon">🥇</div>
|
||||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
|
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[1])" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
|
||||||
<div class="podium-score-wrap">
|
<div class="podium-score-wrap">
|
||||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
|
||||||
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
|
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
|
||||||
@@ -280,7 +281,7 @@
|
|||||||
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
|
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
|
||||||
</div>
|
</div>
|
||||||
<div class="podium-medal-icon">🥉</div>
|
<div class="podium-medal-icon">🥉</div>
|
||||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
|
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[2])" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
|
||||||
<div class="podium-score-wrap">
|
<div class="podium-score-wrap">
|
||||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
|
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
|
||||||
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
|
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
|
||||||
@@ -289,8 +290,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<footer class="live-mode-footer live-mode-fixed-footer">
|
||||||
|
<div class="live-footer-slot left">
|
||||||
|
<img src="/simon_logo.png" alt="Simon logo" class="live-footer-logo" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="live-footer-slot center">
|
||||||
|
<img src="/arture_logo.png" alt="Arture logo" class="live-footer-logo" />
|
||||||
|
</div>
|
||||||
|
<div class="live-footer-slot right">
|
||||||
|
<img src="/datwyler_logo.png" alt="Datwyler logo" class="live-footer-logo" />
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|||||||
@@ -41,11 +41,12 @@
|
|||||||
<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">
|
||||||
<input
|
<input
|
||||||
class="score-input score-input-compact"
|
class="score-input score-input-compact"
|
||||||
|
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId) }"
|
||||||
type="number"
|
type="number"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
min="0"
|
min="0"
|
||||||
max="9999"
|
max="100"
|
||||||
:value="scoreInputValue(round.stage, row.playerId)"
|
:value="scoreInputValue(round.stage, row.playerId)"
|
||||||
@focus="onScoreFocus(round.stage, row.playerId)"
|
@focus="onScoreFocus(round.stage, row.playerId)"
|
||||||
@input="onScoreInput(round.stage, row.playerId, $event)"
|
@input="onScoreInput(round.stage, row.playerId, $event)"
|
||||||
@@ -103,11 +104,12 @@
|
|||||||
<span class="score-label">{{ round.label }}</span>
|
<span class="score-label">{{ round.label }}</span>
|
||||||
<input
|
<input
|
||||||
class="score-input"
|
class="score-input"
|
||||||
|
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId) }"
|
||||||
type="number"
|
type="number"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
min="0"
|
min="0"
|
||||||
max="9999"
|
max="100"
|
||||||
:value="scoreInputValue(round.stage, row.playerId)"
|
:value="scoreInputValue(round.stage, row.playerId)"
|
||||||
@focus="onScoreFocus(round.stage, row.playerId)"
|
@focus="onScoreFocus(round.stage, row.playerId)"
|
||||||
@input="onScoreInput(round.stage, row.playerId, $event)"
|
@input="onScoreInput(round.stage, row.playerId, $event)"
|
||||||
@@ -164,6 +166,7 @@ const props = defineProps({
|
|||||||
displayName: { type: Function, required: true },
|
displayName: { type: Function, required: true },
|
||||||
secondaryName: { type: Function, required: true },
|
secondaryName: { type: Function, required: true },
|
||||||
scoreInputValue: { type: Function, required: true },
|
scoreInputValue: { type: Function, required: true },
|
||||||
|
scoreInputInvalid: { type: Function, required: true },
|
||||||
onScoreFocus: { type: Function, required: true },
|
onScoreFocus: { type: Function, required: true },
|
||||||
onScoreInput: { type: Function, required: true },
|
onScoreInput: { type: Function, required: true },
|
||||||
onScoreCommit: { type: Function, required: true },
|
onScoreCommit: { type: Function, required: true },
|
||||||
|
|||||||
@@ -40,11 +40,12 @@
|
|||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
class="score-input"
|
class="score-input"
|
||||||
|
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId) }"
|
||||||
type="number"
|
type="number"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
min="0"
|
min="0"
|
||||||
max="9999"
|
max="100"
|
||||||
:value="scoreInputValue(stage, row.playerId)"
|
:value="scoreInputValue(stage, row.playerId)"
|
||||||
@focus="onScoreFocus(stage, row.playerId)"
|
@focus="onScoreFocus(stage, row.playerId)"
|
||||||
@input="onScoreInput(stage, row.playerId, $event)"
|
@input="onScoreInput(stage, row.playerId, $event)"
|
||||||
@@ -109,11 +110,12 @@
|
|||||||
<label class="score-label">{{ inputLabel }}</label>
|
<label class="score-label">{{ inputLabel }}</label>
|
||||||
<input
|
<input
|
||||||
class="score-input"
|
class="score-input"
|
||||||
|
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId) }"
|
||||||
type="number"
|
type="number"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
min="0"
|
min="0"
|
||||||
max="9999"
|
max="100"
|
||||||
:value="scoreInputValue(stage, row.playerId)"
|
:value="scoreInputValue(stage, row.playerId)"
|
||||||
@focus="onScoreFocus(stage, row.playerId)"
|
@focus="onScoreFocus(stage, row.playerId)"
|
||||||
@input="onScoreInput(stage, row.playerId, $event)"
|
@input="onScoreInput(stage, row.playerId, $event)"
|
||||||
@@ -170,6 +172,7 @@ const props = defineProps({
|
|||||||
displayName: { type: Function, required: true },
|
displayName: { type: Function, required: true },
|
||||||
secondaryName: { type: Function, required: true },
|
secondaryName: { type: Function, required: true },
|
||||||
scoreInputValue: { type: Function, required: true },
|
scoreInputValue: { type: Function, required: true },
|
||||||
|
scoreInputInvalid: { type: Function, required: true },
|
||||||
onScoreFocus: { type: Function, required: true },
|
onScoreFocus: { type: Function, required: true },
|
||||||
onScoreInput: { type: Function, required: true },
|
onScoreInput: { type: Function, required: true },
|
||||||
onScoreCommit: { type: Function, required: true },
|
onScoreCommit: { type: Function, required: true },
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const I18N = {
|
|||||||
liveTracker: 'LIVE TRACKER',
|
liveTracker: 'LIVE TRACKER',
|
||||||
mode: 'الوضع',
|
mode: 'الوضع',
|
||||||
language: 'اللغة',
|
language: 'اللغة',
|
||||||
|
fullscreen: 'ملء الشاشة',
|
||||||
lastSync: 'آخر مزامنة',
|
lastSync: 'آخر مزامنة',
|
||||||
loading: 'جاري تحميل بيانات البطولة...',
|
loading: 'جاري تحميل بيانات البطولة...',
|
||||||
players: 'لاعب',
|
players: 'لاعب',
|
||||||
@@ -139,6 +140,8 @@ export const I18N = {
|
|||||||
resetPosition: 'إعادة الضبط',
|
resetPosition: 'إعادة الضبط',
|
||||||
liveModeRotate: 'تدوير تلقائي',
|
liveModeRotate: 'تدوير تلقائي',
|
||||||
liveModeFixed: 'ثابت',
|
liveModeFixed: 'ثابت',
|
||||||
|
enterFullscreen: 'دخول ملء الشاشة',
|
||||||
|
exitFullscreen: 'الخروج من ملء الشاشة',
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
username: 'اسم المستخدم',
|
username: 'اسم المستخدم',
|
||||||
@@ -166,7 +169,7 @@ export const I18N = {
|
|||||||
confirmDelete: 'هل تريد حذف اللاعب؟',
|
confirmDelete: 'هل تريد حذف اللاعب؟',
|
||||||
confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟',
|
confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟',
|
||||||
resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟',
|
resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟',
|
||||||
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 9999.',
|
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 100.',
|
||||||
unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.',
|
unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.',
|
||||||
errorPrefix: 'حدث خطأ',
|
errorPrefix: 'حدث خطأ',
|
||||||
noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.',
|
noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.',
|
||||||
@@ -190,6 +193,7 @@ export const I18N = {
|
|||||||
liveTracker: 'LIVE TRACKER',
|
liveTracker: 'LIVE TRACKER',
|
||||||
mode: 'Mode',
|
mode: 'Mode',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
|
fullscreen: 'Fullscreen',
|
||||||
lastSync: 'Last sync',
|
lastSync: 'Last sync',
|
||||||
loading: 'Loading tournament data...',
|
loading: 'Loading tournament data...',
|
||||||
players: 'Players',
|
players: 'Players',
|
||||||
@@ -315,6 +319,8 @@ export const I18N = {
|
|||||||
resetPosition: 'Reset position',
|
resetPosition: 'Reset position',
|
||||||
liveModeRotate: 'Auto rotate',
|
liveModeRotate: 'Auto rotate',
|
||||||
liveModeFixed: 'Fixed',
|
liveModeFixed: 'Fixed',
|
||||||
|
enterFullscreen: 'Enter Fullscreen',
|
||||||
|
exitFullscreen: 'Exit Fullscreen',
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
@@ -342,7 +348,7 @@ export const I18N = {
|
|||||||
confirmDelete: 'Delete this player?',
|
confirmDelete: 'Delete this player?',
|
||||||
confirmReset: 'Reset this stage scores?',
|
confirmReset: 'Reset this stage scores?',
|
||||||
resetProofPrompt: 'Also remove all proof images for this stage?',
|
resetProofPrompt: 'Also remove all proof images for this stage?',
|
||||||
invalidScore: 'Score must be between 0 and 9999.',
|
invalidScore: 'Score must be between 0 and 100.',
|
||||||
unauthorized: 'Admin session expired. Please login again.',
|
unauthorized: 'Admin session expired. Please login again.',
|
||||||
errorPrefix: 'Error',
|
errorPrefix: 'Error',
|
||||||
noGroupsConfigured: 'No primary groups configured.',
|
noGroupsConfigured: 'No primary groups configured.',
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ body {
|
|||||||
.control-toggle-row {
|
.control-toggle-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-toggle-btn {
|
.control-toggle-btn {
|
||||||
@@ -138,6 +140,35 @@ body {
|
|||||||
background: linear-gradient(180deg, #f0f6ff 0%, #e6f0ff 100%);
|
background: linear-gradient(180deg, #f0f6ff 0%, #e6f0ff 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.control-icon-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border: 1px solid #cfd6e7;
|
||||||
|
background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #445478;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 12px rgba(27, 31, 64, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-icon-btn:hover {
|
||||||
|
background: linear-gradient(180deg, #f0f6ff 0%, #e6f0ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-icon-btn.active {
|
||||||
|
background: var(--navy);
|
||||||
|
border-color: var(--navy);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-icon-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.control-toggle-meta {
|
.control-toggle-meta {
|
||||||
font-family: "IBM Plex Mono", monospace;
|
font-family: "IBM Plex Mono", monospace;
|
||||||
color: #6c7a97;
|
color: #6c7a97;
|
||||||
@@ -901,6 +932,23 @@ select.name-input {
|
|||||||
color: #7d87a1;
|
color: #7d87a1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.score-input-over-limit {
|
||||||
|
border-color: #ec2f23 !important;
|
||||||
|
color: #8e1a14;
|
||||||
|
background: #fff2f1;
|
||||||
|
animation: score-over-limit-blink 0.7s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes score-over-limit-blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(236, 47, 35, 0.18);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 4px rgba(236, 47, 35, 0.28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.proof-actions {
|
.proof-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1123,12 +1171,18 @@ select.name-input {
|
|||||||
color: var(--ok);
|
color: var(--ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-mode-root {
|
||||||
|
position: relative;
|
||||||
|
--live-footer-height: 74px;
|
||||||
|
--live-footer-offset: 16px;
|
||||||
|
padding-bottom: calc(var(--live-footer-height) + var(--live-footer-offset) + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
.live-mode-shell {
|
.live-mode-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 72vh;
|
min-height: 72vh;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-image: url('/bg_live.png');
|
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
@@ -1139,12 +1193,12 @@ select.name-input {
|
|||||||
.live-mode-overlay {
|
.live-mode-overlay {
|
||||||
min-height: 72vh;
|
min-height: 72vh;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: linear-gradient(135deg, rgba(7, 18, 43, 0.72) 0%, rgba(9, 27, 56, 0.6) 45%, rgba(12, 30, 68, 0.66) 100%);
|
background: #171b3c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-mode-header h2 {
|
.live-mode-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #f8d78b;
|
color: rgba(240, 248, 255, 0.84);
|
||||||
font-size: clamp(22px, 3vw, 34px);
|
font-size: clamp(22px, 3vw, 34px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1167,7 +1221,7 @@ select.name-input {
|
|||||||
.live-screen-card {
|
.live-screen-card {
|
||||||
border: 1px solid rgba(200, 223, 255, 0.28);
|
border: 1px solid rgba(200, 223, 255, 0.28);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(8, 16, 38, 0.62);
|
background: #1a1e3f;
|
||||||
box-shadow: 0 8px 18px rgba(7, 19, 42, 0.18);
|
box-shadow: 0 8px 18px rgba(7, 19, 42, 0.18);
|
||||||
backdrop-filter: blur(3px);
|
backdrop-filter: blur(3px);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -1186,7 +1240,7 @@ select.name-input {
|
|||||||
.live-focus-banner {
|
.live-focus-banner {
|
||||||
border: 1px solid rgba(168, 205, 242, 0.4);
|
border: 1px solid rgba(168, 205, 242, 0.4);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: linear-gradient(135deg, rgba(16, 32, 72, 0.86) 0%, rgba(11, 27, 60, 0.84) 100%);
|
background: rgba(23, 27, 60, 0.9);
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -1211,28 +1265,13 @@ select.name-input {
|
|||||||
text-shadow: 0 3px 10px rgba(0, 0, 0, 0.35);
|
text-shadow: 0 3px 10px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-focus-banner.focus-a .live-focus-title {
|
.live-focus-banner.focus-a .live-focus-title,
|
||||||
color: #98c4ff;
|
.live-focus-banner.focus-b .live-focus-title,
|
||||||
}
|
.live-focus-banner.focus-c .live-focus-title,
|
||||||
|
.live-focus-banner.focus-d .live-focus-title,
|
||||||
.live-focus-banner.focus-b .live-focus-title {
|
.live-focus-banner.focus-u .live-focus-title,
|
||||||
color: #8de6ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-focus-banner.focus-c .live-focus-title {
|
|
||||||
color: #ffad9d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-focus-banner.focus-d .live-focus-title {
|
|
||||||
color: #ffd98f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-focus-banner.focus-u .live-focus-title {
|
|
||||||
color: #d9e3f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.live-focus-banner.focus-final .live-focus-title {
|
.live-focus-banner.focus-final .live-focus-title {
|
||||||
color: #f9e3a1;
|
color: #d9e3f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-screen-head {
|
.live-screen-head {
|
||||||
@@ -1245,6 +1284,10 @@ select.name-input {
|
|||||||
|
|
||||||
.live-head-selection {
|
.live-head-selection {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-screen-head h3 {
|
.live-screen-head h3 {
|
||||||
@@ -1293,6 +1336,56 @@ select.name-input {
|
|||||||
filter: blur(1px);
|
filter: blur(1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-mode-footer {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(158, 198, 238, 0.38);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #1a1e3f;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-mode-fixed-footer {
|
||||||
|
position: fixed;
|
||||||
|
inset-inline: 24px;
|
||||||
|
bottom: calc(var(--live-footer-offset) + env(safe-area-inset-bottom));
|
||||||
|
box-shadow: 0 10px 24px rgba(8, 18, 39, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-footer-slot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-footer-slot.left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-footer-slot.center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-footer-slot.right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-footer-logo {
|
||||||
|
max-width: min(35vw, 200px);
|
||||||
|
padding: 10px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-footer-slot.right .live-footer-logo {
|
||||||
|
max-width: min(45vw, 280px);
|
||||||
|
}
|
||||||
|
|
||||||
.multi-round-cell {
|
.multi-round-cell {
|
||||||
min-width: 98px;
|
min-width: 98px;
|
||||||
}
|
}
|
||||||
@@ -1376,7 +1469,7 @@ select.name-input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.live-score-table thead th {
|
.live-score-table thead th {
|
||||||
background: linear-gradient(90deg, #11254a 0%, #124665 100%);
|
background: #171b3c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-screen-card .score-table td {
|
.live-screen-card .score-table td {
|
||||||
@@ -1391,49 +1484,103 @@ select.name-input {
|
|||||||
|
|
||||||
.live-screen-card .table-wrap {
|
.live-screen-card .table-wrap {
|
||||||
border-color: rgba(158, 186, 216, 0.28);
|
border-color: rgba(158, 186, 216, 0.28);
|
||||||
background: rgba(11, 24, 49, 0.65);
|
background: #171b3c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-screen-card .qualified-row {
|
.live-screen-card .qualified-row {
|
||||||
background: linear-gradient(90deg, rgba(16, 160, 194, 0.24), rgba(16, 160, 194, 0.06));
|
background: rgba(16, 160, 194, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-podium {
|
.live-podium {
|
||||||
margin-top: 60px;
|
margin-top: 72px;
|
||||||
height: 390px;
|
min-height: 500px;
|
||||||
|
gap: 22px;
|
||||||
|
padding: 0 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.podium-live-card .podium-col {
|
.podium-live-card .podium-col {
|
||||||
background: linear-gradient(180deg, rgba(12, 23, 52, 0.95) 0%, rgba(9, 19, 43, 0.95) 100%);
|
background: #1a1e3f;
|
||||||
border: 1px solid rgba(177, 205, 236, 0.26);
|
border: 1px solid rgba(177, 205, 236, 0.26);
|
||||||
box-shadow: 0 16px 30px rgba(2, 10, 25, 0.45);
|
box-shadow: 0 18px 34px rgba(2, 10, 25, 0.45);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 330px;
|
||||||
|
border-radius: 22px 22px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.podium-live-card .podium-col.pos-1 {
|
.podium-live-card .podium-col.pos-1 {
|
||||||
border-top: 8px solid #e7c45f;
|
border-top: 10px solid rgba(231, 196, 95, 0.4);
|
||||||
background: linear-gradient(180deg, rgba(48, 38, 15, 0.95) 0%, rgba(18, 24, 48, 0.96) 100%);
|
background: #1a1e3f;
|
||||||
height: 330px;
|
height: 430px;
|
||||||
|
box-shadow: 0 22px 44px rgba(231, 196, 95, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.podium-live-card .podium-col.pos-2 {
|
.podium-live-card .podium-col.pos-2 {
|
||||||
border-top: 8px solid #b8c3d7;
|
border-top: 10px solid rgba(184, 195, 215, 0.4);
|
||||||
background: linear-gradient(180deg, rgba(37, 44, 62, 0.95) 0%, rgba(12, 20, 43, 0.96) 100%);
|
background: #1a1e3f;
|
||||||
height: 260px;
|
height: 350px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.podium-live-card .podium-col.pos-3 {
|
.podium-live-card .podium-col.pos-3 {
|
||||||
border-top: 8px solid #cf8b45;
|
border-top: 10px solid rgba(207, 139, 69, 0.4);
|
||||||
background: linear-gradient(180deg, rgba(59, 36, 23, 0.95) 0%, rgba(14, 22, 45, 0.96) 100%);
|
background: #1a1e3f;
|
||||||
height: 240px;
|
height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium-live-card .podium-avatar-wrap {
|
||||||
|
top: -82px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium-live-card .podium-col.pos-1 .podium-avatar-wrap {
|
||||||
|
top: -112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium-live-card .podium-img {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
border-width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium-live-card .podium-col.pos-1 .podium-img {
|
||||||
|
width: 210px;
|
||||||
|
height: 210px;
|
||||||
|
border-width: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.podium-live-card .podium-name {
|
.podium-live-card .podium-name {
|
||||||
color: #f2f8ff;
|
color: #f2f8ff;
|
||||||
|
font-size: clamp(19px, 1.8vw, 24px);
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
max-width: calc(100% - 20px);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
unicode-bidi: plaintext;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 2.4em;
|
||||||
|
max-height: 2.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium-live-card .podium-col.pos-1 .podium-name {
|
||||||
|
font-size: clamp(22px, 2.2vw, 30px);
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
|
min-height: 3.6em;
|
||||||
|
max-height: 3.6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.podium-live-card .podium-name.empty {
|
.podium-live-card .podium-name.empty {
|
||||||
color: rgba(204, 220, 240, 0.78);
|
color: rgba(204, 220, 240, 0.78);
|
||||||
|
font-size: 22px;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
min-height: 2.4em;
|
||||||
|
max-height: 2.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.podium-live-card .podium-score {
|
.podium-live-card .podium-score {
|
||||||
@@ -1441,12 +1588,29 @@ select.name-input {
|
|||||||
color: #ffd888;
|
color: #ffd888;
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium-live-card .podium-col.pos-1 .podium-score {
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 12px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.podium-live-card .podium-medal-icon {
|
.podium-live-card .podium-medal-icon {
|
||||||
filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.35));
|
filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.35));
|
||||||
|
font-size: 60px;
|
||||||
|
margin-top: 86px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.podium-live-card .podium-col.pos-1 .podium-medal-icon {
|
||||||
|
font-size: 84px;
|
||||||
|
margin-top: 118px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.podium-panel {
|
.podium-panel {
|
||||||
background: #f0f2f8;
|
background: #f0f2f8;
|
||||||
}
|
}
|
||||||
@@ -2111,10 +2275,15 @@ select.name-input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.control-toggle-btn {
|
.control-toggle-btn {
|
||||||
width: 100%;
|
flex: 1;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.control-icon-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
grid-auto-columns: minmax(126px, 1fr);
|
grid-auto-columns: minmax(126px, 1fr);
|
||||||
}
|
}
|
||||||
@@ -2170,14 +2339,19 @@ select.name-input {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-mode-root {
|
||||||
|
--live-footer-height: 62px;
|
||||||
|
--live-footer-offset: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.live-mode-shell {
|
.live-mode-shell {
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
min-height: 0;
|
min-height: 70dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-mode-overlay {
|
.live-mode-overlay {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
min-height: 0;
|
min-height: 70dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-mode-grid {
|
.live-mode-grid {
|
||||||
@@ -2193,6 +2367,23 @@ select.name-input {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-mode-footer {
|
||||||
|
padding: 8px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-mode-fixed-footer {
|
||||||
|
inset-inline: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-footer-logo {
|
||||||
|
max-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-footer-slot.right .live-footer-logo {
|
||||||
|
max-width: min(40vw, 150px);
|
||||||
|
}
|
||||||
|
|
||||||
.status-row {
|
.status-row {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
@@ -2229,6 +2420,36 @@ select.name-input {
|
|||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.podium-live-card .podium-img {
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium-live-card .podium-col.pos-1 .podium-img {
|
||||||
|
width: 156px;
|
||||||
|
height: 156px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium-live-card .podium-medal-icon {
|
||||||
|
font-size: 50px;
|
||||||
|
margin-top: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium-live-card .podium-col.pos-1 .podium-medal-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-top: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium-live-card .podium-name {
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
min-height: 3.45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium-live-card .podium-col.pos-1 .podium-name {
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
min-height: 3.45em;
|
||||||
|
}
|
||||||
|
|
||||||
.competitor-image {
|
.competitor-image {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
|
|||||||
137
getdata.json
Normal file
137
getdata.json
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
{
|
||||||
|
"response": {
|
||||||
|
"protocolVersion": {
|
||||||
|
"protocol": "HTTP",
|
||||||
|
"minor": 1,
|
||||||
|
"major": 1
|
||||||
|
},
|
||||||
|
"allHeaders": [],
|
||||||
|
"statusLine": {
|
||||||
|
"statusCode": 200,
|
||||||
|
"protocolVersion": {
|
||||||
|
"protocol": "HTTP",
|
||||||
|
"minor": 1,
|
||||||
|
"major": 1
|
||||||
|
},
|
||||||
|
"reasonPhrase": "OK"
|
||||||
|
},
|
||||||
|
"locale": "en_GB",
|
||||||
|
"params": {
|
||||||
|
"names": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data": [
|
||||||
|
[
|
||||||
|
"Input Voltage",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
"V"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"I/P Fault Voltage",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
"V"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Output Voltage",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
"V"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Load",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
"%"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Input Frequency",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
"Hz"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Battery Cell Voltage",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
"V"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Temperature",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
"℃"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Utility State",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Battery Low Voltage",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Bypass Mode",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Fault State",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"UPS Type",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Testing",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Shutdown Active",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Buzzer",
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"0.0",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Communication State",
|
||||||
|
"2026.04.28 08:05:16",
|
||||||
|
"0.0",
|
||||||
|
"Lost",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
5
phoenix/.formatter.exs
Normal file
5
phoenix/.formatter.exs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
import_deps: [:ecto, :ecto_sql, :phoenix],
|
||||||
|
subdirectories: ["priv/*/migrations"],
|
||||||
|
inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"]
|
||||||
|
]
|
||||||
31
phoenix/.gitignore
vendored
Normal file
31
phoenix/.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# The directory Mix will write compiled artifacts to.
|
||||||
|
/_build/
|
||||||
|
|
||||||
|
# If you run "mix test --cover", coverage assets end up here.
|
||||||
|
/cover/
|
||||||
|
|
||||||
|
# The directory Mix downloads your dependencies sources to.
|
||||||
|
/deps/
|
||||||
|
|
||||||
|
# Where 3rd-party dependencies like ExDoc output generated docs.
|
||||||
|
/doc/
|
||||||
|
|
||||||
|
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||||
|
/.fetch
|
||||||
|
|
||||||
|
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||||
|
erl_crash.dump
|
||||||
|
|
||||||
|
# Also ignore archive artifacts (built via "mix archive.build").
|
||||||
|
*.ez
|
||||||
|
|
||||||
|
# Temporary files, for example, from tests.
|
||||||
|
/tmp/
|
||||||
|
|
||||||
|
# Ignore package tarball (built via "mix hex.build").
|
||||||
|
shooting_event_phx-*.tar
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
|
||||||
111
phoenix/AGENTS.md
Normal file
111
phoenix/AGENTS.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
This is a web application written using the Phoenix web framework.
|
||||||
|
|
||||||
|
## Project guidelines
|
||||||
|
|
||||||
|
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
|
||||||
|
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
|
||||||
|
|
||||||
|
### Phoenix v1.8 guidelines
|
||||||
|
|
||||||
|
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
|
||||||
|
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
|
||||||
|
- Anytime you run into errors with no `current_scope` assign:
|
||||||
|
- You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
|
||||||
|
- **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
|
||||||
|
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
|
||||||
|
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
|
||||||
|
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors
|
||||||
|
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
|
||||||
|
custom classes must fully style the input
|
||||||
|
|
||||||
|
|
||||||
|
<!-- usage-rules-start -->
|
||||||
|
|
||||||
|
<!-- phoenix:elixir-start -->
|
||||||
|
## Elixir guidelines
|
||||||
|
|
||||||
|
- Elixir lists **do not support index based access via the access syntax**
|
||||||
|
|
||||||
|
**Never do this (invalid)**:
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
mylist = ["blue", "green"]
|
||||||
|
mylist[i]
|
||||||
|
|
||||||
|
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
mylist = ["blue", "green"]
|
||||||
|
Enum.at(mylist, i)
|
||||||
|
|
||||||
|
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
|
||||||
|
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
|
||||||
|
|
||||||
|
# INVALID: we are rebinding inside the `if` and the result never gets assigned
|
||||||
|
if connected?(socket) do
|
||||||
|
socket = assign(socket, :val, val)
|
||||||
|
end
|
||||||
|
|
||||||
|
# VALID: we rebind the result of the `if` to a new variable
|
||||||
|
socket =
|
||||||
|
if connected?(socket) do
|
||||||
|
assign(socket, :val, val)
|
||||||
|
end
|
||||||
|
|
||||||
|
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
|
||||||
|
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
|
||||||
|
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
|
||||||
|
- Don't use `String.to_atom/1` on user input (memory leak risk)
|
||||||
|
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
|
||||||
|
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
|
||||||
|
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
|
||||||
|
|
||||||
|
## Mix guidelines
|
||||||
|
|
||||||
|
- Read the docs and options before using tasks (by using `mix help task_name`)
|
||||||
|
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
|
||||||
|
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
|
||||||
|
|
||||||
|
## Test guidelines
|
||||||
|
|
||||||
|
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
|
||||||
|
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
|
||||||
|
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
|
||||||
|
|
||||||
|
ref = Process.monitor(pid)
|
||||||
|
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
|
||||||
|
|
||||||
|
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
|
||||||
|
<!-- phoenix:elixir-end -->
|
||||||
|
|
||||||
|
<!-- phoenix:phoenix-start -->
|
||||||
|
## Phoenix guidelines
|
||||||
|
|
||||||
|
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
|
||||||
|
|
||||||
|
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
|
||||||
|
|
||||||
|
scope "/admin", AppWeb.Admin do
|
||||||
|
pipe_through :browser
|
||||||
|
|
||||||
|
live "/users", UserLive, :index
|
||||||
|
end
|
||||||
|
|
||||||
|
the UserLive route would point to the `AppWeb.Admin.UserLive` module
|
||||||
|
|
||||||
|
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
|
||||||
|
<!-- phoenix:phoenix-end -->
|
||||||
|
|
||||||
|
<!-- phoenix:ecto-start -->
|
||||||
|
## Ecto Guidelines
|
||||||
|
|
||||||
|
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
|
||||||
|
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
|
||||||
|
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
|
||||||
|
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
|
||||||
|
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
|
||||||
|
- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
|
||||||
|
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
|
||||||
|
<!-- phoenix:ecto-end -->
|
||||||
|
|
||||||
|
<!-- usage-rules-end -->
|
||||||
51
phoenix/README.md
Normal file
51
phoenix/README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Shooting Event Phoenix Backend
|
||||||
|
|
||||||
|
This folder contains a Phoenix + SQLite implementation of the existing Go API contract used by the Vue frontend.
|
||||||
|
|
||||||
|
## Run (dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd phoenix
|
||||||
|
mix deps.get
|
||||||
|
mix ecto.create
|
||||||
|
mix ecto.migrate
|
||||||
|
PORT=8080 mix phx.server
|
||||||
|
```
|
||||||
|
|
||||||
|
Or from project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make phoenix-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make phoenix-build
|
||||||
|
```
|
||||||
|
|
||||||
|
## API compatibility
|
||||||
|
|
||||||
|
The Phoenix app exposes the same `/api/*` endpoints currently used by the Vue app, including:
|
||||||
|
|
||||||
|
- Public:
|
||||||
|
- `GET /api/health`
|
||||||
|
- `GET /api/state`
|
||||||
|
- `GET /api/events` (SSE)
|
||||||
|
- Admin:
|
||||||
|
- `POST /api/admin/login`
|
||||||
|
- `POST /api/admin/logout`
|
||||||
|
- `GET /api/admin/state`
|
||||||
|
- `PUT /api/admin/settings`
|
||||||
|
- Player CRUD + auto-group
|
||||||
|
- Score/proof update/delete + reset
|
||||||
|
- AI score advice endpoint
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- `PORT` (default: `8080`)
|
||||||
|
- `DB_PATH` (default: `../data/shooting_phoenix.db`)
|
||||||
|
- `ADMIN_USER` (default: `datwyler`)
|
||||||
|
- `ADMIN_PASS` (default: `datwyler`)
|
||||||
|
- `GEMINI_API_KEY` (default follows existing project fallback)
|
||||||
|
- `GEMINI_MODEL` (default: `gemini-3.1-flash-lite-preview`)
|
||||||
35
phoenix/config/config.exs
Normal file
35
phoenix/config/config.exs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# This file is responsible for configuring your application
|
||||||
|
# and its dependencies with the aid of the Config module.
|
||||||
|
#
|
||||||
|
# This configuration file is loaded before any dependency and
|
||||||
|
# is restricted to this project.
|
||||||
|
|
||||||
|
# General application configuration
|
||||||
|
import Config
|
||||||
|
|
||||||
|
config :shooting_event_phx,
|
||||||
|
ecto_repos: [ShootingEventPhx.Repo],
|
||||||
|
generators: [timestamp_type: :utc_datetime]
|
||||||
|
|
||||||
|
# Configure the endpoint
|
||||||
|
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
|
||||||
|
url: [host: "localhost"],
|
||||||
|
adapter: Bandit.PhoenixAdapter,
|
||||||
|
render_errors: [
|
||||||
|
formats: [json: ShootingEventPhxWeb.ErrorJSON],
|
||||||
|
layout: false
|
||||||
|
],
|
||||||
|
pubsub_server: ShootingEventPhx.PubSub,
|
||||||
|
live_view: [signing_salt: "FIh+7Vu7"]
|
||||||
|
|
||||||
|
# Configure Elixir's Logger
|
||||||
|
config :logger, :default_formatter,
|
||||||
|
format: "$time $metadata[$level] $message\n",
|
||||||
|
metadata: [:request_id]
|
||||||
|
|
||||||
|
# Use Jason for JSON parsing in Phoenix
|
||||||
|
config :phoenix, :json_library, Jason
|
||||||
|
|
||||||
|
# Import environment specific config. This must remain at the bottom
|
||||||
|
# of this file so it overrides the configuration defined above.
|
||||||
|
import_config "#{config_env()}.exs"
|
||||||
60
phoenix/config/dev.exs
Normal file
60
phoenix/config/dev.exs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import Config
|
||||||
|
|
||||||
|
# Configure your database
|
||||||
|
config :shooting_event_phx, ShootingEventPhx.Repo,
|
||||||
|
database: Path.expand("../shooting_event_phx_dev.db", __DIR__),
|
||||||
|
pool_size: 5,
|
||||||
|
stacktrace: true,
|
||||||
|
show_sensitive_data_on_connection_error: true
|
||||||
|
|
||||||
|
# For development, we disable any cache and enable
|
||||||
|
# debugging and code reloading.
|
||||||
|
#
|
||||||
|
# The watchers configuration can be used to run external
|
||||||
|
# watchers to your application. For example, we can use it
|
||||||
|
# to bundle .js and .css sources.
|
||||||
|
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
|
||||||
|
# Binding to loopback ipv4 address prevents access from other machines.
|
||||||
|
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
|
||||||
|
http: [ip: {127, 0, 0, 1}],
|
||||||
|
check_origin: false,
|
||||||
|
code_reloader: true,
|
||||||
|
debug_errors: true,
|
||||||
|
secret_key_base: "lw8ztXfcXK/Q4GVw+LtQGEo34zfdV+uCkmdj2BkwfDlxIZzE8jQyMbU8AqKNBoVW",
|
||||||
|
watchers: []
|
||||||
|
|
||||||
|
# ## SSL Support
|
||||||
|
#
|
||||||
|
# In order to use HTTPS in development, a self-signed
|
||||||
|
# certificate can be generated by running the following
|
||||||
|
# Mix task:
|
||||||
|
#
|
||||||
|
# mix phx.gen.cert
|
||||||
|
#
|
||||||
|
# Run `mix help phx.gen.cert` for more information.
|
||||||
|
#
|
||||||
|
# The `http:` config above can be replaced with:
|
||||||
|
#
|
||||||
|
# https: [
|
||||||
|
# port: 4001,
|
||||||
|
# cipher_suite: :strong,
|
||||||
|
# keyfile: "priv/cert/selfsigned_key.pem",
|
||||||
|
# certfile: "priv/cert/selfsigned.pem"
|
||||||
|
# ],
|
||||||
|
#
|
||||||
|
# If desired, both `http:` and `https:` keys can be
|
||||||
|
# configured to run both http and https servers on
|
||||||
|
# different ports.
|
||||||
|
|
||||||
|
# Enable dev routes for dashboard and mailbox
|
||||||
|
config :shooting_event_phx, dev_routes: true
|
||||||
|
|
||||||
|
# Do not include metadata nor timestamps in development logs
|
||||||
|
config :logger, :default_formatter, format: "[$level] $message\n"
|
||||||
|
|
||||||
|
# Set a higher stacktrace during development. Avoid configuring such
|
||||||
|
# in production as building large stacktraces may be expensive.
|
||||||
|
config :phoenix, :stacktrace_depth, 20
|
||||||
|
|
||||||
|
# Initialize plugs at runtime for faster development compilation
|
||||||
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
19
phoenix/config/prod.exs
Normal file
19
phoenix/config/prod.exs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import Config
|
||||||
|
|
||||||
|
# Force using SSL in production. This also sets the "strict-security-transport" header,
|
||||||
|
# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
|
||||||
|
# Note `:force_ssl` is required to be set at compile-time.
|
||||||
|
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
|
||||||
|
force_ssl: [
|
||||||
|
rewrite_on: [:x_forwarded_proto],
|
||||||
|
exclude: [
|
||||||
|
# paths: ["/health"],
|
||||||
|
hosts: ["localhost", "127.0.0.1"]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Do not print debug messages in production
|
||||||
|
config :logger, level: :info
|
||||||
|
|
||||||
|
# Runtime production configuration, including reading
|
||||||
|
# of environment variables, is done on config/runtime.exs.
|
||||||
92
phoenix/config/runtime.exs
Normal file
92
phoenix/config/runtime.exs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import Config
|
||||||
|
|
||||||
|
# config/runtime.exs is executed for all environments, including
|
||||||
|
# during releases. It is executed after compilation and before the
|
||||||
|
# system starts, so it is typically used to load production configuration
|
||||||
|
# and secrets from environment variables or elsewhere. Do not define
|
||||||
|
# any compile-time configuration in here, as it won't be applied.
|
||||||
|
# The block below contains prod specific runtime configuration.
|
||||||
|
|
||||||
|
# ## Using releases
|
||||||
|
#
|
||||||
|
# If you use `mix release`, you need to explicitly enable the server
|
||||||
|
# by passing the PHX_SERVER=true when you start it:
|
||||||
|
#
|
||||||
|
# PHX_SERVER=true bin/shooting_event_phx start
|
||||||
|
#
|
||||||
|
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
|
||||||
|
# script that automatically sets the env var above.
|
||||||
|
if System.get_env("PHX_SERVER") do
|
||||||
|
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint, server: true
|
||||||
|
end
|
||||||
|
|
||||||
|
default_db_path = Path.expand("../../data/shooting_phoenix.db", __DIR__)
|
||||||
|
database_path = System.get_env("DB_PATH") || System.get_env("DATABASE_PATH") || default_db_path
|
||||||
|
|
||||||
|
config :shooting_event_phx, ShootingEventPhx.Repo,
|
||||||
|
database: database_path,
|
||||||
|
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
|
||||||
|
|
||||||
|
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
|
||||||
|
http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT", "8080"))]
|
||||||
|
|
||||||
|
if config_env() == :prod do
|
||||||
|
# The secret key base is used to sign/encrypt cookies and other secrets.
|
||||||
|
# A default value is used in config/dev.exs and config/test.exs but you
|
||||||
|
# want to use a different value for prod and you most likely don't want
|
||||||
|
# to check this value into version control, so we use an environment
|
||||||
|
# variable instead.
|
||||||
|
secret_key_base =
|
||||||
|
System.get_env("SECRET_KEY_BASE") ||
|
||||||
|
raise """
|
||||||
|
environment variable SECRET_KEY_BASE is missing.
|
||||||
|
You can generate one by calling: mix phx.gen.secret
|
||||||
|
"""
|
||||||
|
|
||||||
|
host = System.get_env("PHX_HOST") || "example.com"
|
||||||
|
|
||||||
|
config :shooting_event_phx, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||||
|
|
||||||
|
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
|
||||||
|
url: [host: host, port: 443, scheme: "https"],
|
||||||
|
http: [
|
||||||
|
# Enable IPv6 and bind on all interfaces.
|
||||||
|
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||||
|
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
|
||||||
|
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||||
|
ip: {0, 0, 0, 0, 0, 0, 0, 0}
|
||||||
|
],
|
||||||
|
secret_key_base: secret_key_base
|
||||||
|
|
||||||
|
# ## SSL Support
|
||||||
|
#
|
||||||
|
# To get SSL working, you will need to add the `https` key
|
||||||
|
# to your endpoint configuration:
|
||||||
|
#
|
||||||
|
# config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
|
||||||
|
# https: [
|
||||||
|
# ...,
|
||||||
|
# port: 443,
|
||||||
|
# cipher_suite: :strong,
|
||||||
|
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
|
||||||
|
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
|
||||||
|
# ]
|
||||||
|
#
|
||||||
|
# The `cipher_suite` is set to `:strong` to support only the
|
||||||
|
# latest and more secure SSL ciphers. This means old browsers
|
||||||
|
# and clients may not be supported. You can set it to
|
||||||
|
# `:compatible` for wider support.
|
||||||
|
#
|
||||||
|
# `:keyfile` and `:certfile` expect an absolute path to the key
|
||||||
|
# and cert in disk or a relative path inside priv, for example
|
||||||
|
# "priv/ssl/server.key". For all supported SSL configuration
|
||||||
|
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
|
||||||
|
#
|
||||||
|
# We also recommend setting `force_ssl` in your config/prod.exs,
|
||||||
|
# ensuring no data is ever sent via http, always redirecting to https:
|
||||||
|
#
|
||||||
|
# config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
|
||||||
|
# force_ssl: [hsts: true]
|
||||||
|
#
|
||||||
|
# Check `Plug.SSL` for all available options in `force_ssl`.
|
||||||
|
end
|
||||||
28
phoenix/config/test.exs
Normal file
28
phoenix/config/test.exs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Config
|
||||||
|
|
||||||
|
# Configure your database
|
||||||
|
#
|
||||||
|
# The MIX_TEST_PARTITION environment variable can be used
|
||||||
|
# to provide built-in test partitioning in CI environment.
|
||||||
|
# Run `mix help test` for more information.
|
||||||
|
config :shooting_event_phx, ShootingEventPhx.Repo,
|
||||||
|
database: Path.expand("../shooting_event_phx_test.db", __DIR__),
|
||||||
|
pool_size: 5,
|
||||||
|
pool: Ecto.Adapters.SQL.Sandbox
|
||||||
|
|
||||||
|
# We don't run a server during test. If one is required,
|
||||||
|
# you can enable the server option below.
|
||||||
|
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
|
||||||
|
http: [ip: {127, 0, 0, 1}, port: 4002],
|
||||||
|
secret_key_base: "h/THyq4VyIU51PLFwsrgMS2n9bs3Dj+RkC7my5e/FhUC91q7EUB3WcYfdvEKJgX3",
|
||||||
|
server: false
|
||||||
|
|
||||||
|
# Print only warnings and errors during test
|
||||||
|
config :logger, level: :warning
|
||||||
|
|
||||||
|
# Initialize plugs at runtime for faster test compilation
|
||||||
|
config :phoenix, :plug_init_mode, :runtime
|
||||||
|
|
||||||
|
# Sort query params output of verified routes for robust url comparisons
|
||||||
|
config :phoenix,
|
||||||
|
sort_verified_routes_query_params: true
|
||||||
9
phoenix/lib/shooting_event_phx.ex
Normal file
9
phoenix/lib/shooting_event_phx.ex
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
defmodule ShootingEventPhx do
|
||||||
|
@moduledoc """
|
||||||
|
ShootingEventPhx keeps the contexts that define your domain
|
||||||
|
and business logic.
|
||||||
|
|
||||||
|
Contexts are also responsible for managing your data, regardless
|
||||||
|
if it comes from the database, an external API or others.
|
||||||
|
"""
|
||||||
|
end
|
||||||
199
phoenix/lib/shooting_event_phx/ai/gemini.ex
Normal file
199
phoenix/lib/shooting_event_phx/ai/gemini.ex
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
defmodule ShootingEventPhx.AI.Gemini do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
def generate_score_advice(stage, player_id, image_data, current_score) do
|
||||||
|
with api_key when is_binary(api_key) and api_key != "" <- gemini_api_key(),
|
||||||
|
{:ok, mime_type, raw_base64} <- parse_data_uri(image_data),
|
||||||
|
{:ok, body} <- build_request_body(stage, current_score, mime_type, raw_base64),
|
||||||
|
{:ok, response} <- request_gemini(body, api_key),
|
||||||
|
{:ok, model_response} <- parse_gemini_response(response) do
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
"stage" => stage,
|
||||||
|
"playerId" => player_id,
|
||||||
|
"advisedScore" => clamp(model_response["advisedScore"] || 0, 0, 9999),
|
||||||
|
"reason" => normalize_reason(model_response["reason"]),
|
||||||
|
"generatedAt" =>
|
||||||
|
DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||||||
|
}}
|
||||||
|
else
|
||||||
|
nil -> {:error, :service_unavailable, "gemini api key is not configured"}
|
||||||
|
{:error, message} when is_binary(message) -> {:error, :bad_gateway, message}
|
||||||
|
{:error, status, message} -> {:error, status, message}
|
||||||
|
_ -> {:error, :bad_gateway, "failed to generate score advice"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp gemini_api_key do
|
||||||
|
System.get_env("GEMINI_API_KEY", "AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc")
|
||||||
|
|> String.trim()
|
||||||
|
|> case do
|
||||||
|
"" -> nil
|
||||||
|
v -> v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp gemini_model do
|
||||||
|
System.get_env("GEMINI_MODEL", "gemini-3.1-flash-lite-preview")
|
||||||
|
|> String.trim()
|
||||||
|
|> case do
|
||||||
|
"" -> "gemini-3.1-flash-lite-preview"
|
||||||
|
v -> v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_data_uri(data_uri) do
|
||||||
|
value = data_uri |> to_string() |> String.trim()
|
||||||
|
|
||||||
|
if String.starts_with?(value, "data:") do
|
||||||
|
case String.split(value, ",", parts: 2) do
|
||||||
|
[header, payload] ->
|
||||||
|
payload = String.trim(payload)
|
||||||
|
|
||||||
|
if payload == "" do
|
||||||
|
{:error, "invalid proof image data: empty payload"}
|
||||||
|
else
|
||||||
|
mime_type =
|
||||||
|
case String.split(header, ";", parts: 2) do
|
||||||
|
["data:" <> mime, _] when mime != "" -> mime
|
||||||
|
_ -> "image/jpeg"
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, mime_type, payload}
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:error, "invalid proof image data: invalid data uri format"}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, "invalid proof image data: expected data uri"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_request_body(stage, current_score, mime_type, raw_base64) do
|
||||||
|
request = %{
|
||||||
|
"contents" => [
|
||||||
|
%{
|
||||||
|
"role" => "user",
|
||||||
|
"parts" => [
|
||||||
|
%{"text" => score_advice_prompt(stage, current_score)},
|
||||||
|
%{"inline_data" => %{"mime_type" => mime_type, "data" => raw_base64}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"generationConfig" => %{
|
||||||
|
"temperature" => 0,
|
||||||
|
"responseMimeType" => "application/json",
|
||||||
|
"maxOutputTokens" => 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, Jason.encode!(request)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp request_gemini(body, api_key) do
|
||||||
|
endpoint =
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models/#{URI.encode(gemini_model())}:generateContent?key=#{URI.encode_www_form(api_key)}"
|
||||||
|
|
||||||
|
case Req.post(endpoint,
|
||||||
|
headers: [{"content-type", "application/json"}],
|
||||||
|
body: body,
|
||||||
|
receive_timeout: 25_000
|
||||||
|
) do
|
||||||
|
{:ok, %Req.Response{status: status, body: response_body}} when status < 300 ->
|
||||||
|
{:ok, response_body}
|
||||||
|
|
||||||
|
{:ok, %Req.Response{status: status, body: response_body}} ->
|
||||||
|
{:error, "gemini api status #{status}: #{inspect(response_body)}"}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, "call gemini api: #{inspect(reason)}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_gemini_response(response_body) when is_map(response_body) do
|
||||||
|
cond do
|
||||||
|
is_map(response_body["error"]) and is_binary(response_body["error"]["message"]) ->
|
||||||
|
{:error, "gemini api error: #{response_body["error"]["message"]}"}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
text =
|
||||||
|
get_in(response_body, [
|
||||||
|
"candidates",
|
||||||
|
Access.at(0),
|
||||||
|
"content",
|
||||||
|
"parts",
|
||||||
|
Access.at(0),
|
||||||
|
"text"
|
||||||
|
])
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|
||||||
|
json_text = extract_json_object(text)
|
||||||
|
|
||||||
|
if json_text == "" do
|
||||||
|
{:error, "gemini response was not valid json"}
|
||||||
|
else
|
||||||
|
case Jason.decode(json_text) do
|
||||||
|
{:ok, parsed} -> {:ok, parsed}
|
||||||
|
{:error, reason} -> {:error, "parse gemini advice json: #{inspect(reason)}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_gemini_response(_), do: {:error, "decode gemini response: unexpected payload"}
|
||||||
|
|
||||||
|
defp extract_json_object(raw) do
|
||||||
|
text =
|
||||||
|
raw
|
||||||
|
|> String.trim()
|
||||||
|
|> String.trim_leading("```json")
|
||||||
|
|> String.trim_leading("```")
|
||||||
|
|> String.trim_trailing("```")
|
||||||
|
|> String.trim()
|
||||||
|
|
||||||
|
start_idx = binary_index(text, "{")
|
||||||
|
end_idx = binary_rindex(text, "}")
|
||||||
|
|
||||||
|
if is_integer(start_idx) and is_integer(end_idx) and end_idx > start_idx do
|
||||||
|
text
|
||||||
|
|> String.slice(start_idx..end_idx)
|
||||||
|
|> String.trim()
|
||||||
|
else
|
||||||
|
""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp score_advice_prompt(stage, current_score) do
|
||||||
|
"Target scoring assistant.\nStage: #{stage}. Current score: #{current_score}.\n" <>
|
||||||
|
"Return STRICT JSON only:\n{\"advisedScore\":<int 0..9999>,\"reason\":\"<max 18 words>\"}\n" <>
|
||||||
|
"Do not add markdown or extra fields."
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_reason(reason) do
|
||||||
|
value = reason |> to_string() |> String.trim()
|
||||||
|
if value == "", do: "AI estimated the score from visible impacts.", else: value
|
||||||
|
end
|
||||||
|
|
||||||
|
defp clamp(value, min, _max) when value < min, do: min
|
||||||
|
defp clamp(value, _min, max) when value > max, do: max
|
||||||
|
defp clamp(value, _min, _max), do: value
|
||||||
|
|
||||||
|
defp binary_index(text, pattern) do
|
||||||
|
case :binary.match(text, pattern) do
|
||||||
|
:nomatch -> nil
|
||||||
|
{idx, _len} -> idx
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp binary_rindex(text, pattern) do
|
||||||
|
text
|
||||||
|
|> :binary.matches(pattern)
|
||||||
|
|> List.last()
|
||||||
|
|> case do
|
||||||
|
nil -> nil
|
||||||
|
{idx, _len} -> idx
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
43
phoenix/lib/shooting_event_phx/application.ex
Normal file
43
phoenix/lib/shooting_event_phx/application.ex
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
defmodule ShootingEventPhx.Application do
|
||||||
|
# See https://hexdocs.pm/elixir/Application.html
|
||||||
|
# for more information on OTP Applications
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Application
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def start(_type, _args) do
|
||||||
|
children = [
|
||||||
|
ShootingEventPhxWeb.Telemetry,
|
||||||
|
ShootingEventPhx.Repo,
|
||||||
|
{Ecto.Migrator,
|
||||||
|
repos: Application.fetch_env!(:shooting_event_phx, :ecto_repos), skip: skip_migrations?()},
|
||||||
|
{DNSCluster,
|
||||||
|
query: Application.get_env(:shooting_event_phx, :dns_cluster_query) || :ignore},
|
||||||
|
{Phoenix.PubSub, name: ShootingEventPhx.PubSub},
|
||||||
|
ShootingEventPhx.Auth.SessionStore,
|
||||||
|
# Start a worker by calling: ShootingEventPhx.Worker.start_link(arg)
|
||||||
|
# {ShootingEventPhx.Worker, arg},
|
||||||
|
# Start to serve requests, typically the last entry
|
||||||
|
ShootingEventPhxWeb.Endpoint
|
||||||
|
]
|
||||||
|
|
||||||
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||||
|
# for other strategies and supported options
|
||||||
|
opts = [strategy: :one_for_one, name: ShootingEventPhx.Supervisor]
|
||||||
|
Supervisor.start_link(children, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Tell Phoenix to update the endpoint configuration
|
||||||
|
# whenever the application is updated.
|
||||||
|
@impl true
|
||||||
|
def config_change(changed, _new, removed) do
|
||||||
|
ShootingEventPhxWeb.Endpoint.config_change(changed, removed)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp skip_migrations?() do
|
||||||
|
# By default, sqlite migrations are run when using a release
|
||||||
|
System.get_env("RELEASE_NAME") == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
51
phoenix/lib/shooting_event_phx/auth.ex
Normal file
51
phoenix/lib/shooting_event_phx/auth.ex
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
defmodule ShootingEventPhx.Auth do
|
||||||
|
@moduledoc false
|
||||||
|
alias ShootingEventPhx.Auth.SessionStore
|
||||||
|
|
||||||
|
def admin_user, do: System.get_env("ADMIN_USER", "datwyler")
|
||||||
|
def admin_pass, do: System.get_env("ADMIN_PASS", "datwyler")
|
||||||
|
|
||||||
|
def verify_admin(username, password) do
|
||||||
|
u = username |> to_string() |> String.trim()
|
||||||
|
p = password |> to_string() |> String.trim()
|
||||||
|
|
||||||
|
if u == "" or p == "" do
|
||||||
|
false
|
||||||
|
else
|
||||||
|
secure_compare(u, admin_user()) and secure_compare(p, admin_pass())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def bearer_token(conn) do
|
||||||
|
conn
|
||||||
|
|> Plug.Conn.get_req_header("authorization")
|
||||||
|
|> List.first()
|
||||||
|
|> parse_bearer_token()
|
||||||
|
end
|
||||||
|
|
||||||
|
def admin_request?(conn) do
|
||||||
|
case bearer_token(conn) do
|
||||||
|
nil -> false
|
||||||
|
token -> SessionStore.validate_token(token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_bearer_token(nil), do: nil
|
||||||
|
|
||||||
|
defp parse_bearer_token(header) do
|
||||||
|
value = String.trim(header || "")
|
||||||
|
|
||||||
|
if String.starts_with?(String.downcase(value), "bearer ") do
|
||||||
|
token = value |> String.slice(7..-1//1) |> String.trim()
|
||||||
|
if token == "", do: nil, else: token
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp secure_compare(left, right) do
|
||||||
|
Plug.Crypto.secure_compare(left, right)
|
||||||
|
rescue
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
46
phoenix/lib/shooting_event_phx/auth/session_store.ex
Normal file
46
phoenix/lib/shooting_event_phx/auth/session_store.ex
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
defmodule ShootingEventPhx.Auth.SessionStore do
|
||||||
|
@moduledoc false
|
||||||
|
use Agent
|
||||||
|
|
||||||
|
@duration_seconds 8 * 60 * 60
|
||||||
|
|
||||||
|
def start_link(_opts) do
|
||||||
|
Agent.start_link(fn -> %{} end, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_token do
|
||||||
|
token = :crypto.strong_rand_bytes(32) |> Base.encode16(case: :lower)
|
||||||
|
expires_at = DateTime.add(DateTime.utc_now(), @duration_seconds, :second)
|
||||||
|
Agent.update(__MODULE__, &Map.put(&1, token, expires_at))
|
||||||
|
{:ok, token, expires_at}
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_token(token) when is_binary(token) do
|
||||||
|
purge_expired()
|
||||||
|
|
||||||
|
Agent.get(__MODULE__, fn tokens ->
|
||||||
|
case Map.get(tokens, token) do
|
||||||
|
%DateTime{} = expires_at -> DateTime.compare(DateTime.utc_now(), expires_at) == :lt
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_token(token) when is_binary(token) do
|
||||||
|
Agent.update(__MODULE__, &Map.delete(&1, token))
|
||||||
|
end
|
||||||
|
|
||||||
|
def purge_expired do
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
|
||||||
|
Agent.update(__MODULE__, fn tokens ->
|
||||||
|
Enum.reduce(tokens, %{}, fn {token, expires_at}, acc ->
|
||||||
|
if DateTime.compare(now, expires_at) == :lt do
|
||||||
|
Map.put(acc, token, expires_at)
|
||||||
|
else
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
11
phoenix/lib/shooting_event_phx/domain/app_setting.ex
Normal file
11
phoenix/lib/shooting_event_phx/domain/app_setting.ex
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
defmodule ShootingEventPhx.Domain.AppSetting do
|
||||||
|
use Ecto.Schema
|
||||||
|
|
||||||
|
@primary_key {:key, :string, []}
|
||||||
|
@timestamps_opts [inserted_at: false, updated_at: :updated_at, type: :utc_datetime]
|
||||||
|
schema "app_settings" do
|
||||||
|
field :value, :string, default: ""
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
end
|
||||||
272
phoenix/lib/shooting_event_phx/domain/derived.ex
Normal file
272
phoenix/lib/shooting_event_phx/domain/derived.ex
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
defmodule ShootingEventPhx.Domain.Derived do
|
||||||
|
@moduledoc false
|
||||||
|
alias ShootingEventPhx.Domain.Stages
|
||||||
|
|
||||||
|
def compute(players, scores) do
|
||||||
|
player_by_id = Map.new(players, &{&1.id, &1})
|
||||||
|
|
||||||
|
pre_rows =
|
||||||
|
players
|
||||||
|
|> Enum.map(fn p ->
|
||||||
|
%{
|
||||||
|
"playerId" => p.id,
|
||||||
|
"nameAr" => p.name_ar,
|
||||||
|
"nameEn" => p.name_en,
|
||||||
|
"groupCode" => p.group_code,
|
||||||
|
"imageData" => p.image_data,
|
||||||
|
"score" => score_total(scores, p.id, Stages.preliminary_round_stages()),
|
||||||
|
"tieBreak" => get_score(scores, "prelim_tiebreak", p.id),
|
||||||
|
"rank" => 0,
|
||||||
|
"seed" => 0,
|
||||||
|
"finalGroup" => 0
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|> Enum.sort_by(fn row -> {-row["score"], row["playerId"]} end)
|
||||||
|
|> assign_dense_rank_by_score()
|
||||||
|
|
||||||
|
{pre_tie, finalists} = compute_finalists(pre_rows)
|
||||||
|
|
||||||
|
finalists =
|
||||||
|
finalists
|
||||||
|
|> Enum.with_index(1)
|
||||||
|
|> Enum.map(fn {row, idx} ->
|
||||||
|
row
|
||||||
|
|> Map.put("seed", idx)
|
||||||
|
|> Map.put("finalGroup", if(idx <= 6, do: 1, else: 2))
|
||||||
|
end)
|
||||||
|
|
||||||
|
final_group1 = Enum.filter(finalists, &(&1["finalGroup"] == 1))
|
||||||
|
final_group2 = Enum.filter(finalists, &(&1["finalGroup"] == 2))
|
||||||
|
|
||||||
|
final_rows =
|
||||||
|
finalists
|
||||||
|
|> Enum.map(fn finalist ->
|
||||||
|
p = Map.fetch!(player_by_id, finalist["playerId"])
|
||||||
|
|
||||||
|
%{
|
||||||
|
"playerId" => finalist["playerId"],
|
||||||
|
"nameAr" => p.name_ar,
|
||||||
|
"nameEn" => p.name_en,
|
||||||
|
"groupCode" => p.group_code,
|
||||||
|
"imageData" => p.image_data,
|
||||||
|
"score" => score_total(scores, finalist["playerId"], Stages.final_round_stages()),
|
||||||
|
"tieBreak" => get_score(scores, "final_tiebreak", finalist["playerId"]),
|
||||||
|
"rank" => 0,
|
||||||
|
"seed" => finalist["seed"],
|
||||||
|
"finalGroup" => finalist["finalGroup"]
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|> compute_final_ranking()
|
||||||
|
|
||||||
|
podium = final_rows |> Enum.take(3)
|
||||||
|
final_tie = final_tie_info(final_rows)
|
||||||
|
|
||||||
|
%{
|
||||||
|
"preliminaryRanking" => %{
|
||||||
|
"rows" => pre_rows,
|
||||||
|
"tieBreak" => pre_tie,
|
||||||
|
"unresolved" => pre_tie["required"] and not pre_tie["resolved"]
|
||||||
|
},
|
||||||
|
"finalists" => finalists,
|
||||||
|
"finalGroups" => %{"group1" => final_group1, "group2" => final_group2},
|
||||||
|
"finalRanking" => %{
|
||||||
|
"rows" => final_rows,
|
||||||
|
"tieBreak" => final_tie,
|
||||||
|
"unresolved" => final_tie["required"] and not final_tie["resolved"]
|
||||||
|
},
|
||||||
|
"podium" => podium
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp compute_finalists(pre_rows) do
|
||||||
|
if length(pre_rows) <= 12 do
|
||||||
|
finalists =
|
||||||
|
pre_rows
|
||||||
|
|> Enum.with_index(1)
|
||||||
|
|> Enum.map(fn {row, seed} -> Map.put(row, "seed", seed) end)
|
||||||
|
|
||||||
|
{empty_tie(), finalists}
|
||||||
|
else
|
||||||
|
cutoff = Enum.at(pre_rows, 11)["score"]
|
||||||
|
above = Enum.filter(pre_rows, &(&1["score"] > cutoff))
|
||||||
|
at_cutoff = Enum.filter(pre_rows, &(&1["score"] == cutoff))
|
||||||
|
slots = max(12 - length(above), 0)
|
||||||
|
|
||||||
|
if length(at_cutoff) <= slots do
|
||||||
|
{empty_tie(), above ++ at_cutoff}
|
||||||
|
else
|
||||||
|
tie = %{
|
||||||
|
"required" => true,
|
||||||
|
"resolved" => true,
|
||||||
|
"slots" => slots,
|
||||||
|
"playerIds" => at_cutoff |> Enum.map(& &1["playerId"]) |> Enum.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
sorted_at_cutoff =
|
||||||
|
Enum.sort_by(at_cutoff, fn row -> {-row["tieBreak"], row["playerId"]} end)
|
||||||
|
|
||||||
|
tie =
|
||||||
|
if slots > 0 do
|
||||||
|
boundary = Enum.at(sorted_at_cutoff, slots - 1)["tieBreak"]
|
||||||
|
greater = Enum.count(sorted_at_cutoff, &(&1["tieBreak"] > boundary))
|
||||||
|
equal = Enum.count(sorted_at_cutoff, &(&1["tieBreak"] == boundary))
|
||||||
|
resolved = not (greater < slots and greater + equal > slots)
|
||||||
|
Map.put(tie, "resolved", resolved)
|
||||||
|
else
|
||||||
|
tie
|
||||||
|
end
|
||||||
|
|
||||||
|
finalists = above ++ Enum.take(sorted_at_cutoff, min(slots, length(sorted_at_cutoff)))
|
||||||
|
|
||||||
|
finalists =
|
||||||
|
finalists
|
||||||
|
|> Enum.sort_by(fn row ->
|
||||||
|
if tie["required"] do
|
||||||
|
{-row["score"], -row["tieBreak"], row["playerId"]}
|
||||||
|
else
|
||||||
|
{-row["score"], row["playerId"]}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> assign_dense_rank(fn a, b ->
|
||||||
|
if tie["required"],
|
||||||
|
do: a["score"] == b["score"] and a["tieBreak"] == b["tieBreak"],
|
||||||
|
else: a["score"] == b["score"]
|
||||||
|
end)
|
||||||
|
|
||||||
|
{tie, finalists}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp compute_final_ranking(rows) do
|
||||||
|
sorted = Enum.sort_by(rows, fn row -> {-row["score"], row["seed"]} end)
|
||||||
|
tied_top = tied_top_for_podium(sorted)
|
||||||
|
|
||||||
|
rows =
|
||||||
|
if MapSet.size(tied_top) > 0 do
|
||||||
|
Enum.sort_by(sorted, fn row ->
|
||||||
|
tied = MapSet.member?(tied_top, row["playerId"])
|
||||||
|
tie_break_key = if tied, do: -row["tieBreak"], else: 0
|
||||||
|
{-row["score"], tie_break_key, row["seed"]}
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
sorted
|
||||||
|
end
|
||||||
|
|
||||||
|
if MapSet.size(tied_top) > 0 do
|
||||||
|
assign_dense_rank(rows, fn a, b ->
|
||||||
|
if a["score"] != b["score"] do
|
||||||
|
false
|
||||||
|
else
|
||||||
|
tied_a = MapSet.member?(tied_top, a["playerId"])
|
||||||
|
tied_b = MapSet.member?(tied_top, b["playerId"])
|
||||||
|
if tied_a and tied_b, do: a["tieBreak"] == b["tieBreak"], else: true
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
assign_dense_rank_by_score(rows)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp final_tie_info(final_rows) do
|
||||||
|
tied_top = tied_top_for_podium(final_rows)
|
||||||
|
|
||||||
|
if MapSet.size(tied_top) == 0 do
|
||||||
|
empty_tie()
|
||||||
|
else
|
||||||
|
player_ids = tied_top |> MapSet.to_list() |> Enum.sort()
|
||||||
|
|
||||||
|
resolved =
|
||||||
|
if length(final_rows) >= 3 do
|
||||||
|
third = Enum.at(final_rows, 2)
|
||||||
|
|
||||||
|
{greater, equal} =
|
||||||
|
Enum.reduce(final_rows, {0, 0}, fn row, {g, e} ->
|
||||||
|
cond do
|
||||||
|
row["score"] > third["score"] ->
|
||||||
|
{g + 1, e}
|
||||||
|
|
||||||
|
row["score"] < third["score"] ->
|
||||||
|
{g, e}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
row_tied = MapSet.member?(tied_top, row["playerId"])
|
||||||
|
third_tied = MapSet.member?(tied_top, third["playerId"])
|
||||||
|
|
||||||
|
cond do
|
||||||
|
row_tied and third_tied and row["tieBreak"] > third["tieBreak"] -> {g + 1, e}
|
||||||
|
row_tied and third_tied and row["tieBreak"] == third["tieBreak"] -> {g, e + 1}
|
||||||
|
not row_tied or not third_tied -> {g, e + 1}
|
||||||
|
true -> {g, e}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
not (greater < 3 and greater + equal > 3)
|
||||||
|
else
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
%{"required" => true, "resolved" => resolved, "slots" => 0, "playerIds" => player_ids}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tied_top_for_podium(rows) do
|
||||||
|
rows
|
||||||
|
|> Enum.chunk_by(& &1["score"])
|
||||||
|
|> Enum.reduce({MapSet.new(), 1}, fn chunk, {set, start_pos} ->
|
||||||
|
size = length(chunk)
|
||||||
|
end_pos = start_pos + size - 1
|
||||||
|
score = List.first(chunk)["score"]
|
||||||
|
|
||||||
|
include? =
|
||||||
|
size > 1 and score > 0 and
|
||||||
|
(start_pos <= 3 or end_pos <= 3 or (start_pos < 3 and end_pos > 3))
|
||||||
|
|
||||||
|
next_set =
|
||||||
|
if include? do
|
||||||
|
Enum.reduce(chunk, set, fn row, acc -> MapSet.put(acc, row["playerId"]) end)
|
||||||
|
else
|
||||||
|
set
|
||||||
|
end
|
||||||
|
|
||||||
|
{next_set, end_pos + 1}
|
||||||
|
end)
|
||||||
|
|> elem(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp empty_tie, do: %{"required" => false, "resolved" => true, "slots" => 0, "playerIds" => []}
|
||||||
|
|
||||||
|
defp score_total(scores, player_id, stages) do
|
||||||
|
Enum.reduce(stages, 0, fn stage, acc -> acc + get_score(scores, stage, player_id) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_score(scores, stage, player_id) do
|
||||||
|
stage_map = Map.get(scores, stage, %{})
|
||||||
|
Map.get(stage_map, player_id, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assign_dense_rank_by_score(rows) do
|
||||||
|
assign_dense_rank(rows, fn a, b -> a["score"] == b["score"] end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assign_dense_rank([], _equal_fun), do: []
|
||||||
|
|
||||||
|
defp assign_dense_rank(rows, equal_fun) do
|
||||||
|
{ranked_rev, _prev, _rank, _idx} =
|
||||||
|
Enum.reduce(rows, {[], nil, 1, 0}, fn row, {acc, prev, rank, idx} ->
|
||||||
|
idx1 = idx + 1
|
||||||
|
|
||||||
|
next_rank =
|
||||||
|
if is_map(prev) and equal_fun.(row, prev) do
|
||||||
|
rank
|
||||||
|
else
|
||||||
|
idx1
|
||||||
|
end
|
||||||
|
|
||||||
|
{[%{row | "rank" => next_rank} | acc], row, next_rank, idx1}
|
||||||
|
end)
|
||||||
|
|
||||||
|
Enum.reverse(ranked_rev)
|
||||||
|
end
|
||||||
|
end
|
||||||
353
phoenix/lib/shooting_event_phx/domain/event_data.ex
Normal file
353
phoenix/lib/shooting_event_phx/domain/event_data.ex
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
defmodule ShootingEventPhx.Domain.EventData do
|
||||||
|
@moduledoc false
|
||||||
|
import Ecto.Query
|
||||||
|
alias ShootingEventPhx.Repo
|
||||||
|
alias ShootingEventPhx.Domain.{Player, Score, ScoreAttachment, Stages}
|
||||||
|
|
||||||
|
def list_players do
|
||||||
|
Repo.all(from p in Player, order_by: [asc: p.id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_score_rows(players) do
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
for player <- players, stage <- Stages.score_stages() do
|
||||||
|
Repo.query!(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO scores(stage, player_id, score)
|
||||||
|
VALUES(?1, ?2, 0)
|
||||||
|
""",
|
||||||
|
[stage, player.id]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_scores(players) do
|
||||||
|
base =
|
||||||
|
for stage <- Stages.score_stages(), into: %{} do
|
||||||
|
{stage, Map.new(players, &{&1.id, 0})}
|
||||||
|
end
|
||||||
|
|
||||||
|
rows =
|
||||||
|
Repo.all(
|
||||||
|
from s in Score,
|
||||||
|
select: {s.stage, s.player_id, s.score}
|
||||||
|
)
|
||||||
|
|
||||||
|
Enum.reduce(rows, base, fn {stage, player_id, score}, acc ->
|
||||||
|
if Map.has_key?(acc, stage) do
|
||||||
|
Map.update!(acc, stage, &Map.put(&1, player_id, score))
|
||||||
|
else
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_score_proofs(players) do
|
||||||
|
base =
|
||||||
|
for stage <- Stages.score_stages(), into: %{} do
|
||||||
|
{stage, Map.new(players, &{&1.id, ""})}
|
||||||
|
end
|
||||||
|
|
||||||
|
rows =
|
||||||
|
Repo.all(
|
||||||
|
from sa in ScoreAttachment,
|
||||||
|
select: {sa.stage, sa.player_id, sa.image_data}
|
||||||
|
)
|
||||||
|
|
||||||
|
Enum.reduce(rows, base, fn {stage, player_id, image_data}, acc ->
|
||||||
|
if Map.has_key?(acc, stage) do
|
||||||
|
Map.update!(acc, stage, &Map.put(&1, player_id, image_data || ""))
|
||||||
|
else
|
||||||
|
acc
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def score_map_to_json(scores) do
|
||||||
|
for {stage, stage_map} <- scores, into: %{} do
|
||||||
|
{stage,
|
||||||
|
for({player_id, value} <- stage_map, into: %{}, do: {Integer.to_string(player_id), value})}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def proof_map_to_json(proofs) do
|
||||||
|
for {stage, stage_map} <- proofs, into: %{} do
|
||||||
|
compact =
|
||||||
|
for {player_id, image_data} <- stage_map,
|
||||||
|
String.trim(to_string(image_data)) != "",
|
||||||
|
into: %{} do
|
||||||
|
{Integer.to_string(player_id), image_data}
|
||||||
|
end
|
||||||
|
|
||||||
|
{stage, compact}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_player(attrs) do
|
||||||
|
name_ar = attrs["nameAr"] |> to_string() |> String.trim()
|
||||||
|
name_en = attrs["nameEn"] |> to_string() |> String.trim()
|
||||||
|
group_code = attrs["groupCode"] |> normalize_group()
|
||||||
|
|
||||||
|
cond do
|
||||||
|
name_ar == "" or name_en == "" ->
|
||||||
|
{:error, :bad_request, "both Arabic and English names are required"}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
{:ok, player} =
|
||||||
|
%Player{name_ar: name_ar, name_en: name_en, group_code: group_code, image_data: ""}
|
||||||
|
|> Repo.insert()
|
||||||
|
|
||||||
|
for stage <- Stages.score_stages() do
|
||||||
|
Repo.query!(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO scores(stage, player_id, score) VALUES(?1, ?2, 0)
|
||||||
|
""",
|
||||||
|
[stage, player.id]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, reason} -> {:error, :internal, inspect(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_player(id, attrs) do
|
||||||
|
with %Player{} = player <- Repo.get(Player, id) do
|
||||||
|
updates = player_updates(attrs)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
Map.has_key?(updates, :_error) ->
|
||||||
|
{:error, :bad_request, Map.get(updates, :_error)}
|
||||||
|
|
||||||
|
map_size(updates) == 0 ->
|
||||||
|
{:error, :bad_request, "no fields to update"}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
case Repo.update(Ecto.Changeset.change(player, updates)) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, changeset} -> {:error, :bad_request, format_changeset_errors(changeset)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
nil -> {:error, :not_found, "player not found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_player(id) do
|
||||||
|
case Repo.get(Player, id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found, "player not found"}
|
||||||
|
|
||||||
|
player ->
|
||||||
|
case Repo.delete(player) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, reason} -> {:error, :internal, inspect(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def auto_group_players(groups) do
|
||||||
|
normalized_groups =
|
||||||
|
groups
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.map(&normalize_group/1)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
|> Enum.uniq()
|
||||||
|
|
||||||
|
if normalized_groups == [] do
|
||||||
|
{:error, :bad_request, "at least one group is required"}
|
||||||
|
else
|
||||||
|
player_ids = Repo.all(from p in Player, select: p.id, order_by: [asc: p.id])
|
||||||
|
shuffled = Enum.shuffle(player_ids)
|
||||||
|
|
||||||
|
case Repo.transaction(fn ->
|
||||||
|
Enum.with_index(shuffled)
|
||||||
|
|> Enum.each(fn {player_id, idx} ->
|
||||||
|
group_code = Enum.at(normalized_groups, rem(idx, length(normalized_groups)))
|
||||||
|
|
||||||
|
Repo.query!(
|
||||||
|
"UPDATE players SET group_code = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
|
||||||
|
[group_code, player_id]
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
end) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, reason} -> {:error, :internal, inspect(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_score(stage, player_id, score) when is_integer(score) do
|
||||||
|
if score < 0 or score > 9999 do
|
||||||
|
{:error, :bad_request, "score must be between 0 and 9999"}
|
||||||
|
else
|
||||||
|
Repo.query!(
|
||||||
|
"""
|
||||||
|
INSERT INTO scores(stage, player_id, score)
|
||||||
|
VALUES(?1, ?2, ?3)
|
||||||
|
ON CONFLICT(stage, player_id) DO UPDATE SET score = excluded.score, updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
[stage, player_id, score]
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_score_proof(stage, player_id, image_data) do
|
||||||
|
payload = to_string(image_data || "") |> String.trim()
|
||||||
|
|
||||||
|
cond do
|
||||||
|
payload == "" ->
|
||||||
|
{:error, :bad_request, "imageData is required"}
|
||||||
|
|
||||||
|
byte_size(payload) > 5_000_000 ->
|
||||||
|
{:error, :bad_request, "image payload too large"}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
Repo.query!(
|
||||||
|
"""
|
||||||
|
INSERT INTO score_attachments(stage, player_id, image_data)
|
||||||
|
VALUES(?1, ?2, ?3)
|
||||||
|
ON CONFLICT(stage, player_id) DO UPDATE SET image_data = excluded.image_data, updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
[stage, player_id, payload]
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_score_proof(stage, player_id) do
|
||||||
|
Repo.query!("DELETE FROM score_attachments WHERE stage = ?1 AND player_id = ?2", [
|
||||||
|
stage,
|
||||||
|
player_id
|
||||||
|
])
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def reset_stage_scores(stages, reset_proofs?) do
|
||||||
|
case Repo.transaction(fn ->
|
||||||
|
Enum.each(stages, fn stage ->
|
||||||
|
Repo.query!(
|
||||||
|
"UPDATE scores SET score = 0, updated_at = CURRENT_TIMESTAMP WHERE stage = ?1",
|
||||||
|
[stage]
|
||||||
|
)
|
||||||
|
|
||||||
|
if reset_proofs? do
|
||||||
|
Repo.query!("DELETE FROM score_attachments WHERE stage = ?1", [stage])
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end) do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, reason} -> {:error, :internal, inspect(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_score_proof_image(stage, player_id) do
|
||||||
|
case Repo.one(
|
||||||
|
from sa in ScoreAttachment,
|
||||||
|
where: sa.stage == ^stage and sa.player_id == ^player_id,
|
||||||
|
select: sa.image_data
|
||||||
|
) do
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
image -> {:ok, image}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_current_score(stage, player_id) do
|
||||||
|
score =
|
||||||
|
Repo.one(
|
||||||
|
from s in Score, where: s.stage == ^stage and s.player_id == ^player_id, select: s.score
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, score || 0}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp player_updates(attrs) do
|
||||||
|
updates = %{}
|
||||||
|
|
||||||
|
updates =
|
||||||
|
case Map.get(attrs, "nameAr") do
|
||||||
|
nil ->
|
||||||
|
updates
|
||||||
|
|
||||||
|
name ->
|
||||||
|
trimmed = to_string(name) |> String.trim()
|
||||||
|
|
||||||
|
if trimmed == "",
|
||||||
|
do: Map.put(updates, :_error, "arabic name cannot be empty"),
|
||||||
|
else: Map.put(updates, :name_ar, trimmed)
|
||||||
|
end
|
||||||
|
|
||||||
|
updates =
|
||||||
|
case Map.get(attrs, "nameEn") do
|
||||||
|
nil ->
|
||||||
|
updates
|
||||||
|
|
||||||
|
name ->
|
||||||
|
trimmed = to_string(name) |> String.trim()
|
||||||
|
|
||||||
|
if trimmed == "",
|
||||||
|
do: Map.put(updates, :_error, "english name cannot be empty"),
|
||||||
|
else: Map.put(updates, :name_en, trimmed)
|
||||||
|
end
|
||||||
|
|
||||||
|
updates =
|
||||||
|
if Map.has_key?(attrs, "groupCode"),
|
||||||
|
do: Map.put(updates, :group_code, normalize_group(Map.get(attrs, "groupCode"))),
|
||||||
|
else: updates
|
||||||
|
|
||||||
|
updates =
|
||||||
|
if Map.has_key?(attrs, "imageData") do
|
||||||
|
image = attrs |> Map.get("imageData") |> to_string() |> String.trim()
|
||||||
|
Map.put(updates, :image_data, image)
|
||||||
|
else
|
||||||
|
updates
|
||||||
|
end
|
||||||
|
|
||||||
|
updates =
|
||||||
|
if truthy?(Map.get(attrs, "clearImage")) do
|
||||||
|
Map.put(updates, :image_data, "")
|
||||||
|
else
|
||||||
|
updates
|
||||||
|
end
|
||||||
|
|
||||||
|
case Map.pop(updates, :_error) do
|
||||||
|
{nil, sanitized} ->
|
||||||
|
if byte_size(Map.get(sanitized, :image_data, "")) > 2_500_000 do
|
||||||
|
%{_error: "image payload too large"}
|
||||||
|
else
|
||||||
|
sanitized
|
||||||
|
end
|
||||||
|
|
||||||
|
{message, _} ->
|
||||||
|
%{_error: message}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_group(group), do: group |> to_string() |> String.trim()
|
||||||
|
|
||||||
|
defp truthy?(value) do
|
||||||
|
case value do
|
||||||
|
true -> true
|
||||||
|
1 -> true
|
||||||
|
"1" -> true
|
||||||
|
"true" -> true
|
||||||
|
"yes" -> true
|
||||||
|
"on" -> true
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_changeset_errors(changeset) do
|
||||||
|
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
|
||||||
|
|> Enum.map(fn {field, msgs} -> "#{field} #{Enum.join(msgs, ",")}" end)
|
||||||
|
|> Enum.join("; ")
|
||||||
|
end
|
||||||
|
end
|
||||||
14
phoenix/lib/shooting_event_phx/domain/player.ex
Normal file
14
phoenix/lib/shooting_event_phx/domain/player.ex
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
defmodule ShootingEventPhx.Domain.Player do
|
||||||
|
use Ecto.Schema
|
||||||
|
|
||||||
|
@primary_key {:id, :id, autogenerate: true}
|
||||||
|
@timestamps_opts [inserted_at: :created_at, updated_at: :updated_at, type: :utc_datetime]
|
||||||
|
schema "players" do
|
||||||
|
field :name_ar, :string
|
||||||
|
field :name_en, :string
|
||||||
|
field :group_code, :string, default: ""
|
||||||
|
field :image_data, :string, default: ""
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
end
|
||||||
13
phoenix/lib/shooting_event_phx/domain/score.ex
Normal file
13
phoenix/lib/shooting_event_phx/domain/score.ex
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
defmodule ShootingEventPhx.Domain.Score do
|
||||||
|
use Ecto.Schema
|
||||||
|
|
||||||
|
@primary_key false
|
||||||
|
@timestamps_opts [inserted_at: false, updated_at: :updated_at, type: :utc_datetime]
|
||||||
|
schema "scores" do
|
||||||
|
field :stage, :string, primary_key: true
|
||||||
|
field :player_id, :id, primary_key: true
|
||||||
|
field :score, :integer, default: 0
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
end
|
||||||
13
phoenix/lib/shooting_event_phx/domain/score_attachment.ex
Normal file
13
phoenix/lib/shooting_event_phx/domain/score_attachment.ex
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
defmodule ShootingEventPhx.Domain.ScoreAttachment do
|
||||||
|
use Ecto.Schema
|
||||||
|
|
||||||
|
@primary_key false
|
||||||
|
@timestamps_opts [inserted_at: false, updated_at: :updated_at, type: :utc_datetime]
|
||||||
|
schema "score_attachments" do
|
||||||
|
field :stage, :string, primary_key: true
|
||||||
|
field :player_id, :id, primary_key: true
|
||||||
|
field :image_data, :string, default: ""
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
end
|
||||||
297
phoenix/lib/shooting_event_phx/domain/settings.ex
Normal file
297
phoenix/lib/shooting_event_phx/domain/settings.ex
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
defmodule ShootingEventPhx.Domain.Settings do
|
||||||
|
@moduledoc false
|
||||||
|
import Ecto.Query
|
||||||
|
alias ShootingEventPhx.Repo
|
||||||
|
alias ShootingEventPhx.Domain.AppSetting
|
||||||
|
|
||||||
|
@setting_view_proof_in_view "view_proof_in_view"
|
||||||
|
@setting_live_active_view "live_active_view"
|
||||||
|
@setting_live_show_group_live "live_show_group_live"
|
||||||
|
@setting_live_group_display_mode "live_group_display_mode"
|
||||||
|
@setting_live_group_fixed_code "live_group_fixed_code"
|
||||||
|
@setting_live_show_prelim_tie "live_show_prelim_tie"
|
||||||
|
@setting_live_show_prelim_overall "live_show_prelim_overall"
|
||||||
|
@setting_live_show_final_groups "live_show_final_groups"
|
||||||
|
@setting_live_final_display_mode "live_final_display_mode"
|
||||||
|
@setting_live_final_fixed_group "live_final_fixed_group"
|
||||||
|
@setting_live_show_final_tie "live_show_final_tie"
|
||||||
|
@setting_live_show_podium "live_show_podium"
|
||||||
|
@setting_live_rotation_interval "live_rotation_interval_sec"
|
||||||
|
@setting_live_rotation_players "live_rotation_player_count"
|
||||||
|
|
||||||
|
@allowed_live_views MapSet.new([
|
||||||
|
"group_live",
|
||||||
|
"prelim_tie",
|
||||||
|
"prelim_overall",
|
||||||
|
"final_groups",
|
||||||
|
"final_tie",
|
||||||
|
"podium"
|
||||||
|
])
|
||||||
|
|
||||||
|
def default_live_mode do
|
||||||
|
%{
|
||||||
|
"activeView" => "group_live",
|
||||||
|
"showGroupLive" => true,
|
||||||
|
"groupDisplayMode" => "rotate",
|
||||||
|
"groupFixedCode" => "",
|
||||||
|
"showPrelimTie" => true,
|
||||||
|
"showPrelimOverall" => true,
|
||||||
|
"showFinalGroups" => true,
|
||||||
|
"finalDisplayMode" => "rotate",
|
||||||
|
"finalFixedGroup" => 1,
|
||||||
|
"showFinalTie" => true,
|
||||||
|
"showPodium" => true,
|
||||||
|
"rotationIntervalSec" => 5,
|
||||||
|
"rotationPlayerCount" => 12
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_settings do
|
||||||
|
all =
|
||||||
|
Repo.all(from s in AppSetting, select: {s.key, s.value})
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
live = default_live_mode()
|
||||||
|
|
||||||
|
live =
|
||||||
|
live
|
||||||
|
|> Map.put("showGroupLive", setting_bool(Map.get(all, @setting_live_show_group_live, "1")))
|
||||||
|
|> Map.put(
|
||||||
|
"activeView",
|
||||||
|
normalize_live_active_view(
|
||||||
|
Map.get(all, @setting_live_active_view, live["activeView"]),
|
||||||
|
live["activeView"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> Map.put(
|
||||||
|
"groupDisplayMode",
|
||||||
|
normalize_display_mode(
|
||||||
|
Map.get(all, @setting_live_group_display_mode, live["groupDisplayMode"]),
|
||||||
|
live["groupDisplayMode"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> Map.put(
|
||||||
|
"groupFixedCode",
|
||||||
|
String.upcase(String.trim(Map.get(all, @setting_live_group_fixed_code, "")))
|
||||||
|
)
|
||||||
|
|> Map.put("showPrelimTie", setting_bool(Map.get(all, @setting_live_show_prelim_tie, "1")))
|
||||||
|
|> Map.put(
|
||||||
|
"showPrelimOverall",
|
||||||
|
setting_bool(Map.get(all, @setting_live_show_prelim_overall, "1"))
|
||||||
|
)
|
||||||
|
|> Map.put(
|
||||||
|
"showFinalGroups",
|
||||||
|
setting_bool(Map.get(all, @setting_live_show_final_groups, "1"))
|
||||||
|
)
|
||||||
|
|> Map.put(
|
||||||
|
"finalDisplayMode",
|
||||||
|
normalize_display_mode(
|
||||||
|
Map.get(all, @setting_live_final_display_mode, live["finalDisplayMode"]),
|
||||||
|
live["finalDisplayMode"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> Map.put(
|
||||||
|
"finalFixedGroup",
|
||||||
|
normalize_final_fixed_group(Map.get(all, @setting_live_final_fixed_group, "1"), 1)
|
||||||
|
)
|
||||||
|
|> Map.put("showFinalTie", setting_bool(Map.get(all, @setting_live_show_final_tie, "1")))
|
||||||
|
|> Map.put("showPodium", setting_bool(Map.get(all, @setting_live_show_podium, "1")))
|
||||||
|
|> Map.put(
|
||||||
|
"rotationIntervalSec",
|
||||||
|
normalize_rotation_interval(Map.get(all, @setting_live_rotation_interval, "5"), 5)
|
||||||
|
)
|
||||||
|
|> Map.put(
|
||||||
|
"rotationPlayerCount",
|
||||||
|
normalize_rotation_player_count(Map.get(all, @setting_live_rotation_players, "12"), 12)
|
||||||
|
)
|
||||||
|
|
||||||
|
%{
|
||||||
|
"viewProofInView" => setting_bool(Map.get(all, @setting_view_proof_in_view, "0")),
|
||||||
|
"liveMode" => live
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_settings(%{} = req) do
|
||||||
|
has_update =
|
||||||
|
Map.has_key?(req, "viewProofInView") or
|
||||||
|
Map.has_key?(req, :viewProofInView) or
|
||||||
|
Map.has_key?(req, "liveMode") or
|
||||||
|
Map.has_key?(req, :liveMode)
|
||||||
|
|
||||||
|
if not has_update do
|
||||||
|
{:error, "at least one settings field is required"}
|
||||||
|
else
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
req
|
||||||
|
|> get_value("viewProofInView")
|
||||||
|
|> maybe_upsert(@setting_view_proof_in_view)
|
||||||
|
|
||||||
|
live_patch = get_value(req, "liveMode")
|
||||||
|
if is_map(live_patch), do: persist_live_patch(live_patch)
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
{:ok, _} -> :ok
|
||||||
|
{:error, %Ecto.ConstraintError{} = e} -> {:error, Exception.message(e)}
|
||||||
|
{:error, reason} when is_binary(reason) -> {:error, reason}
|
||||||
|
{:error, reason} -> {:error, inspect(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp persist_live_patch(patch) do
|
||||||
|
patch
|
||||||
|
|> get_value("activeView")
|
||||||
|
|> maybe_upsert(@setting_live_active_view, fn v ->
|
||||||
|
normalize_live_active_view(v, "group_live")
|
||||||
|
end)
|
||||||
|
|
||||||
|
patch
|
||||||
|
|> get_value("showGroupLive")
|
||||||
|
|> maybe_upsert(@setting_live_show_group_live)
|
||||||
|
|
||||||
|
patch
|
||||||
|
|> get_value("groupDisplayMode")
|
||||||
|
|> maybe_upsert(@setting_live_group_display_mode, fn v ->
|
||||||
|
normalize_display_mode(v, "rotate")
|
||||||
|
end)
|
||||||
|
|
||||||
|
patch
|
||||||
|
|> get_value("groupFixedCode")
|
||||||
|
|> maybe_upsert(@setting_live_group_fixed_code, fn v ->
|
||||||
|
v |> to_string() |> String.trim() |> String.upcase()
|
||||||
|
end)
|
||||||
|
|
||||||
|
patch
|
||||||
|
|> get_value("showPrelimTie")
|
||||||
|
|> maybe_upsert(@setting_live_show_prelim_tie)
|
||||||
|
|
||||||
|
patch
|
||||||
|
|> get_value("showPrelimOverall")
|
||||||
|
|> maybe_upsert(@setting_live_show_prelim_overall)
|
||||||
|
|
||||||
|
patch
|
||||||
|
|> get_value("showFinalGroups")
|
||||||
|
|> maybe_upsert(@setting_live_show_final_groups)
|
||||||
|
|
||||||
|
patch
|
||||||
|
|> get_value("finalDisplayMode")
|
||||||
|
|> maybe_upsert(@setting_live_final_display_mode, fn v ->
|
||||||
|
normalize_display_mode(v, "rotate")
|
||||||
|
end)
|
||||||
|
|
||||||
|
patch
|
||||||
|
|> get_value("finalFixedGroup")
|
||||||
|
|> maybe_upsert(@setting_live_final_fixed_group, fn v -> normalize_final_fixed_group(v, 1) end)
|
||||||
|
|
||||||
|
patch
|
||||||
|
|> get_value("showFinalTie")
|
||||||
|
|> maybe_upsert(@setting_live_show_final_tie)
|
||||||
|
|
||||||
|
patch
|
||||||
|
|> get_value("showPodium")
|
||||||
|
|> maybe_upsert(@setting_live_show_podium)
|
||||||
|
|
||||||
|
patch
|
||||||
|
|> get_value("rotationIntervalSec")
|
||||||
|
|> maybe_upsert(@setting_live_rotation_interval, fn v -> normalize_rotation_interval(v, 5) end)
|
||||||
|
|
||||||
|
patch
|
||||||
|
|> get_value("rotationPlayerCount")
|
||||||
|
|> maybe_upsert(@setting_live_rotation_players, fn v ->
|
||||||
|
normalize_rotation_player_count(v, 12)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_upsert(nil, _key), do: :ok
|
||||||
|
|
||||||
|
defp maybe_upsert(value, key) do
|
||||||
|
normalized = setting_string(value)
|
||||||
|
|
||||||
|
Repo.insert!(
|
||||||
|
%AppSetting{key: key, value: normalized},
|
||||||
|
on_conflict: [set: [value: normalized, updated_at: DateTime.utc_now()]],
|
||||||
|
conflict_target: :key
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_upsert(nil, _key, _fun), do: :ok
|
||||||
|
|
||||||
|
defp maybe_upsert(value, key, normalize_fun) do
|
||||||
|
normalized = value |> normalize_fun.() |> setting_string()
|
||||||
|
maybe_upsert(normalized, key)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_value(map, key) when is_map(map) do
|
||||||
|
cond do
|
||||||
|
Map.has_key?(map, key) -> Map.get(map, key)
|
||||||
|
Map.has_key?(map, String.to_atom(key)) -> Map.get(map, String.to_atom(key))
|
||||||
|
true -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp setting_bool(value) do
|
||||||
|
case value |> to_string() |> String.trim() |> String.downcase() do
|
||||||
|
"1" -> true
|
||||||
|
"true" -> true
|
||||||
|
"yes" -> true
|
||||||
|
"on" -> true
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp setting_string(v) when is_boolean(v), do: if(v, do: "1", else: "0")
|
||||||
|
defp setting_string(v) when is_integer(v), do: Integer.to_string(v)
|
||||||
|
defp setting_string(v), do: v |> to_string()
|
||||||
|
|
||||||
|
defp normalize_display_mode(value, fallback) do
|
||||||
|
case value |> to_string() |> String.trim() |> String.downcase() do
|
||||||
|
"fixed" -> "fixed"
|
||||||
|
"rotate" -> "rotate"
|
||||||
|
_ -> if(String.downcase(to_string(fallback)) == "fixed", do: "fixed", else: "rotate")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_final_fixed_group(value, fallback) do
|
||||||
|
parsed =
|
||||||
|
case Integer.parse(to_string(value)) do
|
||||||
|
{v, _} -> v
|
||||||
|
_ -> fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
if parsed == 2, do: 2, else: 1
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_rotation_interval(value, fallback) do
|
||||||
|
parsed =
|
||||||
|
case Integer.parse(to_string(value)) do
|
||||||
|
{v, _} -> v
|
||||||
|
_ -> fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
parsed |> max(3) |> min(30)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_rotation_player_count(value, fallback) do
|
||||||
|
parsed =
|
||||||
|
case Integer.parse(to_string(value)) do
|
||||||
|
{v, _} -> v
|
||||||
|
_ -> fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
parsed |> max(3) |> min(40)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_live_active_view(value, fallback) do
|
||||||
|
normalized =
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.downcase()
|
||||||
|
|
||||||
|
cond do
|
||||||
|
MapSet.member?(@allowed_live_views, normalized) -> normalized
|
||||||
|
MapSet.member?(@allowed_live_views, to_string(fallback)) -> fallback
|
||||||
|
true -> "group_live"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
42
phoenix/lib/shooting_event_phx/domain/stages.ex
Normal file
42
phoenix/lib/shooting_event_phx/domain/stages.ex
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
defmodule ShootingEventPhx.Domain.Stages do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
@preliminary_round_stages ["prelim_r1", "prelim_r2", "prelim_r3"]
|
||||||
|
@final_round_stages ["final_r1", "final_r2"]
|
||||||
|
@tie_break_stages ["prelim_tiebreak", "final_tiebreak"]
|
||||||
|
@score_stages @preliminary_round_stages ++ @final_round_stages ++ @tie_break_stages
|
||||||
|
|
||||||
|
def preliminary_round_stages, do: @preliminary_round_stages
|
||||||
|
def final_round_stages, do: @final_round_stages
|
||||||
|
def tie_break_stages, do: @tie_break_stages
|
||||||
|
def score_stages, do: @score_stages
|
||||||
|
|
||||||
|
def valid_stage?(stage), do: normalize_stage(stage) in @score_stages
|
||||||
|
|
||||||
|
def normalize_stage(stage) do
|
||||||
|
stage
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.downcase()
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_stage(stage) do
|
||||||
|
normalized = normalize_stage(stage)
|
||||||
|
|
||||||
|
if normalized in @score_stages do
|
||||||
|
{:ok, normalized}
|
||||||
|
else
|
||||||
|
{:error, "invalid stage"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_reset_stages(stage) do
|
||||||
|
case normalize_stage(stage) do
|
||||||
|
"preliminary" -> {:ok, @preliminary_round_stages}
|
||||||
|
"final" -> {:ok, @final_round_stages}
|
||||||
|
"prelim_tiebreak" -> {:ok, ["prelim_tiebreak"]}
|
||||||
|
"final_tiebreak" -> {:ok, ["final_tiebreak"]}
|
||||||
|
_ -> {:error, "invalid stage"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
43
phoenix/lib/shooting_event_phx/domain/state_service.ex
Normal file
43
phoenix/lib/shooting_event_phx/domain/state_service.ex
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
defmodule ShootingEventPhx.Domain.StateService do
|
||||||
|
@moduledoc false
|
||||||
|
alias ShootingEventPhx.Domain.{Derived, EventData, Settings}
|
||||||
|
|
||||||
|
def read_state(include_all_proofs \\ false) do
|
||||||
|
players = EventData.list_players()
|
||||||
|
_ = EventData.ensure_score_rows(players)
|
||||||
|
|
||||||
|
settings = Settings.read_settings()
|
||||||
|
scores = EventData.read_scores(players)
|
||||||
|
include_score_proofs = include_all_proofs or settings["viewProofInView"]
|
||||||
|
score_proofs = if include_score_proofs, do: EventData.read_score_proofs(players), else: %{}
|
||||||
|
derived = Derived.compute(players, scores)
|
||||||
|
|
||||||
|
response = %{
|
||||||
|
"competition" => %{
|
||||||
|
"titleAr" => "بطولة دويتوايلر للرماية",
|
||||||
|
"titleEn" => "Datwyler Shooting Event"
|
||||||
|
},
|
||||||
|
"players" => Enum.map(players, &player_to_json/1),
|
||||||
|
"scores" => EventData.score_map_to_json(scores),
|
||||||
|
"settings" => settings,
|
||||||
|
"derived" => derived,
|
||||||
|
"serverTime" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_score_proofs do
|
||||||
|
Map.put(response, "scoreProofs", EventData.proof_map_to_json(score_proofs))
|
||||||
|
else
|
||||||
|
response
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp player_to_json(player) do
|
||||||
|
%{
|
||||||
|
"id" => player.id,
|
||||||
|
"nameAr" => player.name_ar,
|
||||||
|
"nameEn" => player.name_en,
|
||||||
|
"groupCode" => player.group_code,
|
||||||
|
"imageData" => player.image_data
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
15
phoenix/lib/shooting_event_phx/events.ex
Normal file
15
phoenix/lib/shooting_event_phx/events.ex
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
defmodule ShootingEventPhx.Events do
|
||||||
|
@moduledoc false
|
||||||
|
@topic "state_updates"
|
||||||
|
|
||||||
|
def topic, do: @topic
|
||||||
|
|
||||||
|
def subscribe do
|
||||||
|
Phoenix.PubSub.subscribe(ShootingEventPhx.PubSub, @topic)
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast do
|
||||||
|
payload = {:state, DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()}
|
||||||
|
Phoenix.PubSub.broadcast(ShootingEventPhx.PubSub, @topic, payload)
|
||||||
|
end
|
||||||
|
end
|
||||||
5
phoenix/lib/shooting_event_phx/repo.ex
Normal file
5
phoenix/lib/shooting_event_phx/repo.ex
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
defmodule ShootingEventPhx.Repo do
|
||||||
|
use Ecto.Repo,
|
||||||
|
otp_app: :shooting_event_phx,
|
||||||
|
adapter: Ecto.Adapters.SQLite3
|
||||||
|
end
|
||||||
63
phoenix/lib/shooting_event_phx_web.ex
Normal file
63
phoenix/lib/shooting_event_phx_web.ex
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
defmodule ShootingEventPhxWeb do
|
||||||
|
@moduledoc """
|
||||||
|
The entrypoint for defining your web interface, such
|
||||||
|
as controllers, components, channels, and so on.
|
||||||
|
|
||||||
|
This can be used in your application as:
|
||||||
|
|
||||||
|
use ShootingEventPhxWeb, :controller
|
||||||
|
use ShootingEventPhxWeb, :html
|
||||||
|
|
||||||
|
The definitions below will be executed for every controller,
|
||||||
|
component, etc, so keep them short and clean, focused
|
||||||
|
on imports, uses and aliases.
|
||||||
|
|
||||||
|
Do NOT define functions inside the quoted expressions
|
||||||
|
below. Instead, define additional modules and import
|
||||||
|
those modules here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
|
||||||
|
|
||||||
|
def router do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Router, helpers: false
|
||||||
|
|
||||||
|
# Import common connection and controller functions to use in pipelines
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.Controller
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def channel do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Channel
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def controller do
|
||||||
|
quote do
|
||||||
|
use Phoenix.Controller, formats: [:html, :json]
|
||||||
|
|
||||||
|
import Plug.Conn
|
||||||
|
|
||||||
|
unquote(verified_routes())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def verified_routes do
|
||||||
|
quote do
|
||||||
|
use Phoenix.VerifiedRoutes,
|
||||||
|
endpoint: ShootingEventPhxWeb.Endpoint,
|
||||||
|
router: ShootingEventPhxWeb.Router,
|
||||||
|
statics: ShootingEventPhxWeb.static_paths()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
When used, dispatch to the appropriate controller/live_view/etc.
|
||||||
|
"""
|
||||||
|
defmacro __using__(which) when is_atom(which) do
|
||||||
|
apply(__MODULE__, which, [])
|
||||||
|
end
|
||||||
|
end
|
||||||
221
phoenix/lib/shooting_event_phx_web/controllers/api_controller.ex
Normal file
221
phoenix/lib/shooting_event_phx_web/controllers/api_controller.ex
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
defmodule ShootingEventPhxWeb.ApiController do
|
||||||
|
use ShootingEventPhxWeb, :controller
|
||||||
|
alias ShootingEventPhx.{Auth, Events}
|
||||||
|
alias ShootingEventPhx.Auth.SessionStore
|
||||||
|
alias ShootingEventPhx.Domain.{EventData, Settings, Stages, StateService}
|
||||||
|
alias ShootingEventPhx.AI.Gemini
|
||||||
|
|
||||||
|
def health(conn, _params) do
|
||||||
|
json(conn, %{"status" => "ok"})
|
||||||
|
end
|
||||||
|
|
||||||
|
def state(conn, _params) do
|
||||||
|
state = StateService.read_state(Auth.admin_request?(conn))
|
||||||
|
json(conn, state)
|
||||||
|
end
|
||||||
|
|
||||||
|
def admin_state(conn, _params) do
|
||||||
|
json(conn, StateService.read_state(true))
|
||||||
|
end
|
||||||
|
|
||||||
|
def admin_login(conn, params) do
|
||||||
|
username = Map.get(params, "username", "")
|
||||||
|
password = Map.get(params, "password", "")
|
||||||
|
|
||||||
|
if Auth.verify_admin(username, password) do
|
||||||
|
{:ok, token, expires_at} = SessionStore.create_token()
|
||||||
|
|
||||||
|
json(conn, %{
|
||||||
|
"token" => token,
|
||||||
|
"expiresAt" => DateTime.to_iso8601(expires_at),
|
||||||
|
"username" => Auth.admin_user()
|
||||||
|
})
|
||||||
|
else
|
||||||
|
error(conn, :unauthorized, "invalid credentials")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def admin_logout(conn, _params) do
|
||||||
|
case Auth.bearer_token(conn) do
|
||||||
|
nil -> :ok
|
||||||
|
token -> SessionStore.delete_token(token)
|
||||||
|
end
|
||||||
|
|
||||||
|
json(conn, %{"ok" => true})
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_settings(conn, params) do
|
||||||
|
case Settings.update_settings(params) do
|
||||||
|
:ok ->
|
||||||
|
Events.broadcast()
|
||||||
|
json(conn, StateService.read_state(true))
|
||||||
|
|
||||||
|
{:error, message} ->
|
||||||
|
error(conn, :bad_request, message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_player(conn, params) do
|
||||||
|
case EventData.create_player(params) do
|
||||||
|
:ok ->
|
||||||
|
Events.broadcast()
|
||||||
|
conn |> put_status(:created) |> json(StateService.read_state(true))
|
||||||
|
|
||||||
|
{:error, :bad_request, message} ->
|
||||||
|
error(conn, :bad_request, message)
|
||||||
|
|
||||||
|
{:error, _, message} ->
|
||||||
|
error(conn, :internal_server_error, message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_player(conn, %{"id" => id} = params) do
|
||||||
|
with {player_id, ""} <- Integer.parse(to_string(id)),
|
||||||
|
result <- EventData.update_player(player_id, params),
|
||||||
|
:ok <- result do
|
||||||
|
Events.broadcast()
|
||||||
|
json(conn, StateService.read_state(true))
|
||||||
|
else
|
||||||
|
:error -> error(conn, :bad_request, "invalid player id")
|
||||||
|
{:error, :bad_request, message} -> error(conn, :bad_request, message)
|
||||||
|
{:error, :not_found, message} -> error(conn, :not_found, message)
|
||||||
|
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_player(conn, %{"id" => id}) do
|
||||||
|
with {player_id, ""} <- Integer.parse(to_string(id)),
|
||||||
|
:ok <- EventData.delete_player(player_id) do
|
||||||
|
Events.broadcast()
|
||||||
|
json(conn, StateService.read_state(true))
|
||||||
|
else
|
||||||
|
:error -> error(conn, :bad_request, "invalid player id")
|
||||||
|
{:error, :not_found, message} -> error(conn, :not_found, message)
|
||||||
|
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def auto_group_players(conn, params) do
|
||||||
|
groups = Map.get(params, "groups", [])
|
||||||
|
|
||||||
|
case EventData.auto_group_players(groups) do
|
||||||
|
:ok ->
|
||||||
|
Events.broadcast()
|
||||||
|
json(conn, StateService.read_state(true))
|
||||||
|
|
||||||
|
{:error, :bad_request, message} ->
|
||||||
|
error(conn, :bad_request, message)
|
||||||
|
|
||||||
|
{:error, _, message} ->
|
||||||
|
error(conn, :internal_server_error, message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_score(conn, %{"stage" => stage, "id" => id} = params) do
|
||||||
|
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
|
||||||
|
{player_id, ""} <- Integer.parse(to_string(id)),
|
||||||
|
score <- parse_score(Map.get(params, "score")),
|
||||||
|
:ok <- EventData.update_score(normalized_stage, player_id, score) do
|
||||||
|
Events.broadcast()
|
||||||
|
json(conn, StateService.read_state(true))
|
||||||
|
else
|
||||||
|
{:error, message} -> error(conn, :bad_request, message)
|
||||||
|
:error -> error(conn, :bad_request, "invalid player id")
|
||||||
|
{:invalid_score, message} -> error(conn, :bad_request, message)
|
||||||
|
{:error, :bad_request, message} -> error(conn, :bad_request, message)
|
||||||
|
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def reset_stage(conn, %{"stage" => stage} = params) do
|
||||||
|
with {:ok, target_stages} <- Stages.resolve_reset_stages(stage),
|
||||||
|
reset_proofs <- truthy?(Map.get(params, "resetProofs")),
|
||||||
|
:ok <- EventData.reset_stage_scores(target_stages, reset_proofs) do
|
||||||
|
Events.broadcast()
|
||||||
|
json(conn, StateService.read_state(true))
|
||||||
|
else
|
||||||
|
{:error, message} -> error(conn, :bad_request, message)
|
||||||
|
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_score_proof(conn, %{"stage" => stage, "id" => id} = params) do
|
||||||
|
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
|
||||||
|
{player_id, ""} <- Integer.parse(to_string(id)),
|
||||||
|
image_data when is_binary(image_data) <- Map.get(params, "imageData"),
|
||||||
|
:ok <- EventData.update_score_proof(normalized_stage, player_id, image_data) do
|
||||||
|
Events.broadcast()
|
||||||
|
json(conn, StateService.read_state(true))
|
||||||
|
else
|
||||||
|
{:error, message} -> error(conn, :bad_request, message)
|
||||||
|
:error -> error(conn, :bad_request, "invalid player id")
|
||||||
|
nil -> error(conn, :bad_request, "imageData is required")
|
||||||
|
{:error, :bad_request, message} -> error(conn, :bad_request, message)
|
||||||
|
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_score_proof(conn, %{"stage" => stage, "id" => id}) do
|
||||||
|
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
|
||||||
|
{player_id, ""} <- Integer.parse(to_string(id)),
|
||||||
|
:ok <- EventData.delete_score_proof(normalized_stage, player_id) do
|
||||||
|
Events.broadcast()
|
||||||
|
json(conn, StateService.read_state(true))
|
||||||
|
else
|
||||||
|
{:error, message} -> error(conn, :bad_request, message)
|
||||||
|
:error -> error(conn, :bad_request, "invalid player id")
|
||||||
|
{:error, _, message} -> error(conn, :internal_server_error, message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def score_advice(conn, %{"stage" => stage, "id" => id}) do
|
||||||
|
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
|
||||||
|
{player_id, ""} <- Integer.parse(to_string(id)),
|
||||||
|
{:ok, image_data} <- EventData.load_score_proof_image(normalized_stage, player_id),
|
||||||
|
{:ok, current_score} <- EventData.load_current_score(normalized_stage, player_id),
|
||||||
|
{:ok, advice} <-
|
||||||
|
Gemini.generate_score_advice(normalized_stage, player_id, image_data, current_score) do
|
||||||
|
json(conn, advice)
|
||||||
|
else
|
||||||
|
{:error, :not_found} -> error(conn, :bad_request, "no proof image found for this score")
|
||||||
|
{:error, :service_unavailable, message} -> error(conn, :service_unavailable, message)
|
||||||
|
{:error, :bad_gateway, message} -> error(conn, :bad_gateway, message)
|
||||||
|
{:error, message} when is_binary(message) -> error(conn, :bad_request, message)
|
||||||
|
:error -> error(conn, :bad_request, "invalid player id")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_score(value) do
|
||||||
|
cond do
|
||||||
|
is_integer(value) ->
|
||||||
|
value
|
||||||
|
|
||||||
|
is_binary(value) ->
|
||||||
|
case Integer.parse(String.trim(value)) do
|
||||||
|
{v, ""} -> v
|
||||||
|
_ -> {:invalid_score, "invalid request body"}
|
||||||
|
end
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{:invalid_score, "invalid request body"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp truthy?(value) do
|
||||||
|
case value do
|
||||||
|
true -> true
|
||||||
|
1 -> true
|
||||||
|
"1" -> true
|
||||||
|
"true" -> true
|
||||||
|
"yes" -> true
|
||||||
|
"on" -> true
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp error(conn, status, message) do
|
||||||
|
conn
|
||||||
|
|> put_status(status)
|
||||||
|
|> json(%{"message" => message})
|
||||||
|
end
|
||||||
|
end
|
||||||
21
phoenix/lib/shooting_event_phx_web/controllers/error_json.ex
Normal file
21
phoenix/lib/shooting_event_phx_web/controllers/error_json.ex
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
defmodule ShootingEventPhxWeb.ErrorJSON do
|
||||||
|
@moduledoc """
|
||||||
|
This module is invoked by your endpoint in case of errors on JSON requests.
|
||||||
|
|
||||||
|
See config/config.exs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# If you want to customize a particular status code,
|
||||||
|
# you may add your own clauses, such as:
|
||||||
|
#
|
||||||
|
# def render("500.json", _assigns) do
|
||||||
|
# %{errors: %{detail: "Internal Server Error"}}
|
||||||
|
# end
|
||||||
|
|
||||||
|
# By default, Phoenix returns the status message from
|
||||||
|
# the template name. For example, "404.json" becomes
|
||||||
|
# "Not Found".
|
||||||
|
def render(template, _assigns) do
|
||||||
|
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
defmodule ShootingEventPhxWeb.EventsController do
|
||||||
|
use ShootingEventPhxWeb, :controller
|
||||||
|
alias ShootingEventPhx.Events
|
||||||
|
|
||||||
|
def stream(conn, _params) do
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> put_resp_header("content-type", "text/event-stream")
|
||||||
|
|> put_resp_header("cache-control", "no-cache, no-transform")
|
||||||
|
|> put_resp_header("connection", "keep-alive")
|
||||||
|
|> put_resp_header("x-accel-buffering", "no")
|
||||||
|
|> send_chunked(200)
|
||||||
|
|
||||||
|
now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
||||||
|
{:ok, conn} = chunk(conn, "event: ready\ndata: {\"ts\":\"#{now}\"}\n\n")
|
||||||
|
|
||||||
|
:ok = Events.subscribe()
|
||||||
|
loop_stream(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp loop_stream(conn) do
|
||||||
|
receive do
|
||||||
|
{:state, ts} ->
|
||||||
|
case chunk(conn, "event: state\ndata: {\"ts\":\"#{ts}\"}\n\n") do
|
||||||
|
{:ok, conn} -> loop_stream(conn)
|
||||||
|
{:error, :closed} -> conn
|
||||||
|
{:error, _} -> conn
|
||||||
|
end
|
||||||
|
after
|
||||||
|
20_000 ->
|
||||||
|
now_unix = DateTime.utc_now() |> DateTime.to_unix()
|
||||||
|
|
||||||
|
case chunk(conn, ": ping #{now_unix}\n\n") do
|
||||||
|
{:ok, conn} -> loop_stream(conn)
|
||||||
|
{:error, :closed} -> conn
|
||||||
|
{:error, _} -> conn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
defmodule ShootingEventPhxWeb.FrontendController do
|
||||||
|
use ShootingEventPhxWeb, :controller
|
||||||
|
|
||||||
|
def serve(conn, %{"path" => segments}) do
|
||||||
|
request_path =
|
||||||
|
segments
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.join("/")
|
||||||
|
|> String.trim_leading("/")
|
||||||
|
|
||||||
|
if String.starts_with?(request_path, "api") do
|
||||||
|
send_resp(conn, 404, "Not Found")
|
||||||
|
else
|
||||||
|
web_dir = web_dir()
|
||||||
|
requested = if request_path == "", do: "index.html", else: request_path
|
||||||
|
requested_file = safe_file_path(web_dir, requested)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
requested_file != nil and File.regular?(requested_file) ->
|
||||||
|
send_static_file(conn, requested_file)
|
||||||
|
|
||||||
|
File.regular?(Path.join(web_dir, "index.html")) ->
|
||||||
|
send_static_file(conn, Path.join(web_dir, "index.html"))
|
||||||
|
|
||||||
|
true ->
|
||||||
|
send_resp(conn, 404, "frontend build not found. run make build")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp web_dir do
|
||||||
|
System.get_env("WEB_DIR") || Path.expand("../frontend/dist", File.cwd!())
|
||||||
|
end
|
||||||
|
|
||||||
|
defp safe_file_path(web_dir, request_path) do
|
||||||
|
cleaned = request_path |> Path.expand(web_dir)
|
||||||
|
root = Path.expand(web_dir)
|
||||||
|
|
||||||
|
if String.starts_with?(cleaned, root) do
|
||||||
|
cleaned
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp send_static_file(conn, file_path) do
|
||||||
|
content_type =
|
||||||
|
case MIME.from_path(file_path) do
|
||||||
|
"" -> "application/octet-stream"
|
||||||
|
type -> type
|
||||||
|
end
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_resp_content_type(content_type)
|
||||||
|
|> send_file(200, file_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
50
phoenix/lib/shooting_event_phx_web/endpoint.ex
Normal file
50
phoenix/lib/shooting_event_phx_web/endpoint.ex
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
defmodule ShootingEventPhxWeb.Endpoint do
|
||||||
|
use Phoenix.Endpoint, otp_app: :shooting_event_phx
|
||||||
|
|
||||||
|
# The session will be stored in the cookie and signed,
|
||||||
|
# this means its contents can be read but not tampered with.
|
||||||
|
# Set :encryption_salt if you would also like to encrypt it.
|
||||||
|
@session_options [
|
||||||
|
store: :cookie,
|
||||||
|
key: "_shooting_event_phx_key",
|
||||||
|
signing_salt: "RrxJbij4",
|
||||||
|
same_site: "Lax"
|
||||||
|
]
|
||||||
|
|
||||||
|
# socket "/live", Phoenix.LiveView.Socket,
|
||||||
|
# websocket: [connect_info: [session: @session_options]],
|
||||||
|
# longpoll: [connect_info: [session: @session_options]]
|
||||||
|
|
||||||
|
# Serve at "/" the static files from "priv/static" directory.
|
||||||
|
#
|
||||||
|
# When code reloading is disabled (e.g., in production),
|
||||||
|
# the `gzip` option is enabled to serve compressed
|
||||||
|
# static files generated by running `phx.digest`.
|
||||||
|
plug Plug.Static,
|
||||||
|
at: "/",
|
||||||
|
from: :shooting_event_phx,
|
||||||
|
gzip: not code_reloading?,
|
||||||
|
only: ShootingEventPhxWeb.static_paths(),
|
||||||
|
raise_on_missing_only: code_reloading?
|
||||||
|
|
||||||
|
# Code reloading can be explicitly enabled under the
|
||||||
|
# :code_reloader configuration of your endpoint.
|
||||||
|
if code_reloading? do
|
||||||
|
plug Phoenix.CodeReloader
|
||||||
|
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :shooting_event_phx
|
||||||
|
end
|
||||||
|
|
||||||
|
plug Plug.RequestId
|
||||||
|
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||||
|
|
||||||
|
plug Plug.Parsers,
|
||||||
|
parsers: [:urlencoded, :multipart, :json],
|
||||||
|
pass: ["*/*"],
|
||||||
|
json_decoder: Phoenix.json_library()
|
||||||
|
|
||||||
|
plug Plug.MethodOverride
|
||||||
|
plug Plug.Head
|
||||||
|
plug ShootingEventPhxWeb.Plugs.CORS
|
||||||
|
plug Plug.Session, @session_options
|
||||||
|
plug ShootingEventPhxWeb.Router
|
||||||
|
end
|
||||||
18
phoenix/lib/shooting_event_phx_web/plugs/admin_only.ex
Normal file
18
phoenix/lib/shooting_event_phx_web/plugs/admin_only.ex
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
defmodule ShootingEventPhxWeb.Plugs.AdminOnly do
|
||||||
|
@moduledoc false
|
||||||
|
import Plug.Conn
|
||||||
|
alias ShootingEventPhx.Auth
|
||||||
|
|
||||||
|
def init(opts), do: opts
|
||||||
|
|
||||||
|
def call(conn, _opts) do
|
||||||
|
if Auth.admin_request?(conn) do
|
||||||
|
conn
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
|> put_status(:unauthorized)
|
||||||
|
|> Phoenix.Controller.json(%{"message" => "invalid or expired admin token"})
|
||||||
|
|> halt()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
20
phoenix/lib/shooting_event_phx_web/plugs/cors.ex
Normal file
20
phoenix/lib/shooting_event_phx_web/plugs/cors.ex
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
defmodule ShootingEventPhxWeb.Plugs.CORS do
|
||||||
|
@moduledoc false
|
||||||
|
import Plug.Conn
|
||||||
|
|
||||||
|
def init(opts), do: opts
|
||||||
|
|
||||||
|
def call(conn, _opts) do
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> put_resp_header("access-control-allow-origin", "*")
|
||||||
|
|> put_resp_header("access-control-allow-methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||||||
|
|> put_resp_header("access-control-allow-headers", "content-type,authorization")
|
||||||
|
|
||||||
|
if conn.method == "OPTIONS" do
|
||||||
|
conn |> send_resp(204, "") |> halt()
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
49
phoenix/lib/shooting_event_phx_web/router.ex
Normal file
49
phoenix/lib/shooting_event_phx_web/router.ex
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
defmodule ShootingEventPhxWeb.Router do
|
||||||
|
use ShootingEventPhxWeb, :router
|
||||||
|
|
||||||
|
pipeline :api_json do
|
||||||
|
plug :accepts, ["json"]
|
||||||
|
end
|
||||||
|
|
||||||
|
pipeline :frontend do
|
||||||
|
end
|
||||||
|
|
||||||
|
pipeline :admin_api do
|
||||||
|
plug ShootingEventPhxWeb.Plugs.AdminOnly
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/api", ShootingEventPhxWeb do
|
||||||
|
get "/events", EventsController, :stream
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/api", ShootingEventPhxWeb do
|
||||||
|
pipe_through :api_json
|
||||||
|
|
||||||
|
get "/health", ApiController, :health
|
||||||
|
get "/state", ApiController, :state
|
||||||
|
|
||||||
|
post "/admin/login", ApiController, :admin_login
|
||||||
|
|
||||||
|
scope "/admin" do
|
||||||
|
pipe_through :admin_api
|
||||||
|
|
||||||
|
post "/logout", ApiController, :admin_logout
|
||||||
|
get "/state", ApiController, :admin_state
|
||||||
|
put "/settings", ApiController, :update_settings
|
||||||
|
post "/players", ApiController, :create_player
|
||||||
|
post "/players/auto-group", ApiController, :auto_group_players
|
||||||
|
put "/players/:id", ApiController, :update_player
|
||||||
|
delete "/players/:id", ApiController, :delete_player
|
||||||
|
put "/scores/:stage/:id", ApiController, :update_score
|
||||||
|
put "/scores/:stage/:id/proof", ApiController, :update_score_proof
|
||||||
|
delete "/scores/:stage/:id/proof", ApiController, :delete_score_proof
|
||||||
|
post "/scores/:stage/:id/advice", ApiController, :score_advice
|
||||||
|
post "/scores/:stage/reset", ApiController, :reset_stage
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
scope "/", ShootingEventPhxWeb do
|
||||||
|
pipe_through :frontend
|
||||||
|
match :*, "/*path", FrontendController, :serve
|
||||||
|
end
|
||||||
|
end
|
||||||
93
phoenix/lib/shooting_event_phx_web/telemetry.ex
Normal file
93
phoenix/lib/shooting_event_phx_web/telemetry.ex
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
defmodule ShootingEventPhxWeb.Telemetry do
|
||||||
|
use Supervisor
|
||||||
|
import Telemetry.Metrics
|
||||||
|
|
||||||
|
def start_link(arg) do
|
||||||
|
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_arg) do
|
||||||
|
children = [
|
||||||
|
# Telemetry poller will execute the given period measurements
|
||||||
|
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
|
||||||
|
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
|
||||||
|
# Add reporters as children of your supervision tree.
|
||||||
|
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
|
||||||
|
]
|
||||||
|
|
||||||
|
Supervisor.init(children, strategy: :one_for_one)
|
||||||
|
end
|
||||||
|
|
||||||
|
def metrics do
|
||||||
|
[
|
||||||
|
# Phoenix Metrics
|
||||||
|
summary("phoenix.endpoint.start.system_time",
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.endpoint.stop.duration",
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.router_dispatch.start.system_time",
|
||||||
|
tags: [:route],
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.router_dispatch.exception.duration",
|
||||||
|
tags: [:route],
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.router_dispatch.stop.duration",
|
||||||
|
tags: [:route],
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.socket_connected.duration",
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
sum("phoenix.socket_drain.count"),
|
||||||
|
summary("phoenix.channel_joined.duration",
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
summary("phoenix.channel_handled_in.duration",
|
||||||
|
tags: [:event],
|
||||||
|
unit: {:native, :millisecond}
|
||||||
|
),
|
||||||
|
|
||||||
|
# Database Metrics
|
||||||
|
summary("shooting_event_phx.repo.query.total_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description: "The sum of the other measurements"
|
||||||
|
),
|
||||||
|
summary("shooting_event_phx.repo.query.decode_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description: "The time spent decoding the data received from the database"
|
||||||
|
),
|
||||||
|
summary("shooting_event_phx.repo.query.query_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description: "The time spent executing the query"
|
||||||
|
),
|
||||||
|
summary("shooting_event_phx.repo.query.queue_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description: "The time spent waiting for a database connection"
|
||||||
|
),
|
||||||
|
summary("shooting_event_phx.repo.query.idle_time",
|
||||||
|
unit: {:native, :millisecond},
|
||||||
|
description:
|
||||||
|
"The time the connection spent waiting before being checked out for the query"
|
||||||
|
),
|
||||||
|
|
||||||
|
# VM Metrics
|
||||||
|
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||||
|
summary("vm.total_run_queue_lengths.total"),
|
||||||
|
summary("vm.total_run_queue_lengths.cpu"),
|
||||||
|
summary("vm.total_run_queue_lengths.io")
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp periodic_measurements do
|
||||||
|
[
|
||||||
|
# A module, function and arguments to be invoked periodically.
|
||||||
|
# This function must call :telemetry.execute/3 and a metric must be added above.
|
||||||
|
# {ShootingEventPhxWeb, :count_users, []}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
70
phoenix/mix.exs
Normal file
70
phoenix/mix.exs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
defmodule ShootingEventPhx.MixProject do
|
||||||
|
use Mix.Project
|
||||||
|
|
||||||
|
def project do
|
||||||
|
[
|
||||||
|
app: :shooting_event_phx,
|
||||||
|
version: "0.1.0",
|
||||||
|
elixir: "~> 1.15",
|
||||||
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
|
start_permanent: Mix.env() == :prod,
|
||||||
|
aliases: aliases(),
|
||||||
|
deps: deps(),
|
||||||
|
listeners: [Phoenix.CodeReloader]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Configuration for the OTP application.
|
||||||
|
#
|
||||||
|
# Type `mix help compile.app` for more information.
|
||||||
|
def application do
|
||||||
|
[
|
||||||
|
mod: {ShootingEventPhx.Application, []},
|
||||||
|
extra_applications: [:logger, :runtime_tools]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def cli do
|
||||||
|
[
|
||||||
|
preferred_envs: [precommit: :test]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Specifies which paths to compile per environment.
|
||||||
|
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||||
|
defp elixirc_paths(_), do: ["lib"]
|
||||||
|
|
||||||
|
# Specifies your project dependencies.
|
||||||
|
#
|
||||||
|
# Type `mix help deps` for examples and options.
|
||||||
|
defp deps do
|
||||||
|
[
|
||||||
|
{:phoenix, "~> 1.8.5"},
|
||||||
|
{:phoenix_ecto, "~> 4.5"},
|
||||||
|
{:ecto_sql, "~> 3.13"},
|
||||||
|
{:ecto_sqlite3, ">= 0.0.0"},
|
||||||
|
{:telemetry_metrics, "~> 1.0"},
|
||||||
|
{:telemetry_poller, "~> 1.0"},
|
||||||
|
{:jason, "~> 1.2"},
|
||||||
|
{:req, "~> 0.5"},
|
||||||
|
{:dns_cluster, "~> 0.2.0"},
|
||||||
|
{:bandit, "~> 1.5"}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Aliases are shortcuts or tasks specific to the current project.
|
||||||
|
# For example, to install project dependencies and perform other setup tasks, run:
|
||||||
|
#
|
||||||
|
# $ mix setup
|
||||||
|
#
|
||||||
|
# See the documentation for `Mix` for more info on aliases.
|
||||||
|
defp aliases do
|
||||||
|
[
|
||||||
|
setup: ["deps.get", "ecto.setup"],
|
||||||
|
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||||
|
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||||
|
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
|
||||||
|
precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
32
phoenix/mix.lock
Normal file
32
phoenix/mix.lock
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
%{
|
||||||
|
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
|
||||||
|
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||||
|
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
|
||||||
|
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||||
|
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||||
|
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||||
|
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
|
||||||
|
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
|
||||||
|
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||||
|
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
|
||||||
|
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||||
|
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||||
|
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||||
|
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||||
|
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||||
|
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||||
|
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||||
|
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
|
||||||
|
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||||
|
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||||
|
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||||
|
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||||
|
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||||
|
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
||||||
|
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
|
||||||
|
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||||
|
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||||
|
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||||
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
|
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||||
|
}
|
||||||
4
phoenix/priv/repo/migrations/.formatter.exs
Normal file
4
phoenix/priv/repo/migrations/.formatter.exs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[
|
||||||
|
import_deps: [:ecto_sql],
|
||||||
|
inputs: ["*.exs"]
|
||||||
|
]
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
defmodule ShootingEventPhx.Repo.Migrations.InitEventSchema do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS players (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name_ar TEXT NOT NULL,
|
||||||
|
name_en TEXT NOT NULL,
|
||||||
|
group_code TEXT NOT NULL DEFAULT '',
|
||||||
|
image_data TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS scores (
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY(stage, player_id),
|
||||||
|
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS score_attachments (
|
||||||
|
stage TEXT NOT NULL,
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
image_data TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY(stage, player_id),
|
||||||
|
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
INSERT OR IGNORE INTO app_settings(key, value) VALUES
|
||||||
|
('view_proof_in_view', '0'),
|
||||||
|
('live_active_view', 'group_live'),
|
||||||
|
('live_show_group_live', '1'),
|
||||||
|
('live_group_display_mode', 'rotate'),
|
||||||
|
('live_group_fixed_code', ''),
|
||||||
|
('live_show_prelim_tie', '1'),
|
||||||
|
('live_show_prelim_overall', '1'),
|
||||||
|
('live_show_final_groups', '1'),
|
||||||
|
('live_final_display_mode', 'rotate'),
|
||||||
|
('live_final_fixed_group', '1'),
|
||||||
|
('live_show_final_tie', '1'),
|
||||||
|
('live_show_podium', '1'),
|
||||||
|
('live_rotation_interval_sec', '5'),
|
||||||
|
('live_rotation_player_count', '12');
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
INSERT OR IGNORE INTO scores(stage, player_id, score)
|
||||||
|
SELECT 'prelim_r1', player_id, score FROM scores WHERE stage = 'preliminary';
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
INSERT OR IGNORE INTO scores(stage, player_id, score)
|
||||||
|
SELECT 'final_r1', player_id, score FROM scores WHERE stage = 'final';
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
|
||||||
|
SELECT 'prelim_r1', player_id, image_data FROM score_attachments WHERE stage = 'preliminary';
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
|
||||||
|
SELECT 'final_r1', player_id, image_data FROM score_attachments WHERE stage = 'final';
|
||||||
|
""")
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
execute("DROP TABLE IF EXISTS score_attachments;")
|
||||||
|
execute("DROP TABLE IF EXISTS scores;")
|
||||||
|
execute("DROP TABLE IF EXISTS app_settings;")
|
||||||
|
execute("DROP TABLE IF EXISTS players;")
|
||||||
|
end
|
||||||
|
end
|
||||||
11
phoenix/priv/repo/seeds.exs
Normal file
11
phoenix/priv/repo/seeds.exs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Script for populating the database. You can run it as:
|
||||||
|
#
|
||||||
|
# mix run priv/repo/seeds.exs
|
||||||
|
#
|
||||||
|
# Inside the script, you can read and write to any of your
|
||||||
|
# repositories directly:
|
||||||
|
#
|
||||||
|
# ShootingEventPhx.Repo.insert!(%ShootingEventPhx.SomeSchema{})
|
||||||
|
#
|
||||||
|
# We recommend using the bang functions (`insert!`, `update!`
|
||||||
|
# and so on) as they will fail if something goes wrong.
|
||||||
BIN
phoenix/priv/static/favicon.ico
Normal file
BIN
phoenix/priv/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 B |
5
phoenix/priv/static/robots.txt
Normal file
5
phoenix/priv/static/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
|
||||||
|
#
|
||||||
|
# To ban all spiders from the entire site uncomment the next two lines:
|
||||||
|
# User-agent: *
|
||||||
|
# Disallow: /
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
defmodule ShootingEventPhxWeb.ErrorJSONTest do
|
||||||
|
use ShootingEventPhxWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
test "renders 404" do
|
||||||
|
assert ShootingEventPhxWeb.ErrorJSON.render("404.json", %{}) == %{
|
||||||
|
errors: %{detail: "Not Found"}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders 500" do
|
||||||
|
assert ShootingEventPhxWeb.ErrorJSON.render("500.json", %{}) ==
|
||||||
|
%{errors: %{detail: "Internal Server Error"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
38
phoenix/test/support/conn_case.ex
Normal file
38
phoenix/test/support/conn_case.ex
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
defmodule ShootingEventPhxWeb.ConnCase do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines the test case to be used by
|
||||||
|
tests that require setting up a connection.
|
||||||
|
|
||||||
|
Such tests rely on `Phoenix.ConnTest` and also
|
||||||
|
import other functionality to make it easier
|
||||||
|
to build common data structures and query the data layer.
|
||||||
|
|
||||||
|
Finally, if the test case interacts with the database,
|
||||||
|
we enable the SQL sandbox, so changes done to the database
|
||||||
|
are reverted at the end of every test. If you are using
|
||||||
|
PostgreSQL, you can even run database tests asynchronously
|
||||||
|
by setting `use ShootingEventPhxWeb.ConnCase, async: true`, although
|
||||||
|
this option is not recommended for other databases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.CaseTemplate
|
||||||
|
|
||||||
|
using do
|
||||||
|
quote do
|
||||||
|
# The default endpoint for testing
|
||||||
|
@endpoint ShootingEventPhxWeb.Endpoint
|
||||||
|
|
||||||
|
use ShootingEventPhxWeb, :verified_routes
|
||||||
|
|
||||||
|
# Import conveniences for testing with connections
|
||||||
|
import Plug.Conn
|
||||||
|
import Phoenix.ConnTest
|
||||||
|
import ShootingEventPhxWeb.ConnCase
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
setup tags do
|
||||||
|
ShootingEventPhx.DataCase.setup_sandbox(tags)
|
||||||
|
{:ok, conn: Phoenix.ConnTest.build_conn()}
|
||||||
|
end
|
||||||
|
end
|
||||||
58
phoenix/test/support/data_case.ex
Normal file
58
phoenix/test/support/data_case.ex
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
defmodule ShootingEventPhx.DataCase do
|
||||||
|
@moduledoc """
|
||||||
|
This module defines the setup for tests requiring
|
||||||
|
access to the application's data layer.
|
||||||
|
|
||||||
|
You may define functions here to be used as helpers in
|
||||||
|
your tests.
|
||||||
|
|
||||||
|
Finally, if the test case interacts with the database,
|
||||||
|
we enable the SQL sandbox, so changes done to the database
|
||||||
|
are reverted at the end of every test. If you are using
|
||||||
|
PostgreSQL, you can even run database tests asynchronously
|
||||||
|
by setting `use ShootingEventPhx.DataCase, async: true`, although
|
||||||
|
this option is not recommended for other databases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.CaseTemplate
|
||||||
|
|
||||||
|
using do
|
||||||
|
quote do
|
||||||
|
alias ShootingEventPhx.Repo
|
||||||
|
|
||||||
|
import Ecto
|
||||||
|
import Ecto.Changeset
|
||||||
|
import Ecto.Query
|
||||||
|
import ShootingEventPhx.DataCase
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
setup tags do
|
||||||
|
ShootingEventPhx.DataCase.setup_sandbox(tags)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Sets up the sandbox based on the test tags.
|
||||||
|
"""
|
||||||
|
def setup_sandbox(tags) do
|
||||||
|
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(ShootingEventPhx.Repo, shared: not tags[:async])
|
||||||
|
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
A helper that transforms changeset errors into a map of messages.
|
||||||
|
|
||||||
|
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
|
||||||
|
assert "password is too short" in errors_on(changeset).password
|
||||||
|
assert %{password: ["password is too short"]} = errors_on(changeset)
|
||||||
|
|
||||||
|
"""
|
||||||
|
def errors_on(changeset) do
|
||||||
|
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
|
||||||
|
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
|
||||||
|
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
2
phoenix/test/test_helper.exs
Normal file
2
phoenix/test/test_helper.exs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ExUnit.start()
|
||||||
|
Ecto.Adapters.SQL.Sandbox.mode(ShootingEventPhx.Repo, :manual)
|
||||||
Reference in New Issue
Block a user