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
|
||||
CONTAINER_REG ?= repo.ssp-itinfra.com/admin
|
||||
IMAGE_NAME ?= shooting-event
|
||||
PHOENIX_IMAGE_NAME ?= $(IMAGE_NAME)-phoenix
|
||||
ARCH ?= amd64
|
||||
BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
BUILD_VERSION ?= $(shell git describe --tags --always --dirty)
|
||||
|
||||
.PHONY: install dev dev-backend dev-frontend build build-frontend build-backend docker-build docker-run clean
|
||||
.PHONY: install dev dev-backend dev-frontend build build-frontend build-backend phoenix-dev phoenix-build docker-build docker-run docker-build-phoenix docker-run-phoenix clean
|
||||
|
||||
install:
|
||||
cd frontend && $(PNPM) install
|
||||
cd backend && $(GO) mod tidy
|
||||
cd phoenix && mix deps.get
|
||||
|
||||
dev-backend:
|
||||
cd backend && $(GO) run .
|
||||
@@ -40,6 +42,12 @@ build: build-frontend
|
||||
cp -R frontend/dist/. backend/web/
|
||||
$(MAKE) build-backend
|
||||
|
||||
phoenix-dev:
|
||||
cd phoenix && mix deps.get && mix ecto.create && mix ecto.migrate && PORT=$${PORT:-8080} mix phx.server
|
||||
|
||||
phoenix-build:
|
||||
cd phoenix && MIX_ENV=prod mix deps.get --only prod && MIX_ENV=prod mix compile
|
||||
|
||||
docker-build:
|
||||
docker buildx build --load --platform=linux/$(ARCH) -t $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) .
|
||||
docker tag $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-latest
|
||||
@@ -52,7 +60,20 @@ docker-run:
|
||||
mkdir -p data
|
||||
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)
|
||||
|
||||
docker-build-phoenix:
|
||||
docker buildx build --load --platform=linux/$(ARCH) -f Dockerfile.phoenix -t $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) .
|
||||
docker tag $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-latest
|
||||
docker tag $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
|
||||
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)
|
||||
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-latest
|
||||
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
|
||||
|
||||
docker-run-phoenix:
|
||||
mkdir -p data
|
||||
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)
|
||||
|
||||
release:
|
||||
${MAKE} build
|
||||
$(MAKE) docker-build
|
||||
|
||||
clean:
|
||||
|
||||
40
README.md
40
README.md
@@ -34,29 +34,39 @@ Production-ready full-stack web app based on your original live score concept.
|
||||
8. Podium is determined automatically.
|
||||
9. If top-3 tie exists, podium tie-break stage appears.
|
||||
|
||||
## API Highlights
|
||||
## API Documentation (OpenAPI)
|
||||
|
||||
Public:
|
||||
Interactive docs are now built in:
|
||||
|
||||
- `GET /api/health`
|
||||
- `GET /api/state`
|
||||
- Swagger UI: `GET /api/docs`
|
||||
- OpenAPI JSON: `GET /api/openapi.json`
|
||||
|
||||
Admin:
|
||||
### Testing with Auth in Swagger UI
|
||||
|
||||
- `POST /api/admin/login`
|
||||
- `POST /api/admin/logout`
|
||||
- `POST /api/admin/players`
|
||||
- `PUT /api/admin/players/:id`
|
||||
- `DELETE /api/admin/players/:id`
|
||||
- `PUT /api/admin/scores/:stage/:id`
|
||||
- `POST /api/admin/scores/:stage/:id/advice`
|
||||
- `POST /api/admin/scores/:stage/reset`
|
||||
1. Open `/api/docs`.
|
||||
2. Use the **Login & Authorize** form at the top:
|
||||
- Username: `datwyler` (default)
|
||||
- Password: `datwyler` (default)
|
||||
3. The page calls `POST /api/admin/login`, retrieves the token, and auto-authorizes Swagger.
|
||||
4. Use **Try it out** on any admin endpoint.
|
||||
|
||||
Stages:
|
||||
### Stage Values
|
||||
|
||||
For score update/proof/advice endpoints (`/api/admin/scores/{stage}/...`), use:
|
||||
|
||||
- `prelim_r1`
|
||||
- `prelim_r2`
|
||||
- `prelim_r3`
|
||||
- `final_r1`
|
||||
- `final_r2`
|
||||
- `prelim_tiebreak`
|
||||
- `final_tiebreak`
|
||||
|
||||
For reset endpoint (`POST /api/admin/scores/{stage}/reset`), use:
|
||||
|
||||
- `preliminary`
|
||||
- `prelim_tiebreak`
|
||||
- `final`
|
||||
- `prelim_tiebreak`
|
||||
- `final_tiebreak`
|
||||
|
||||
## Local Development
|
||||
|
||||
@@ -69,7 +69,7 @@ func (a *App) loadCurrentScore(stage string, playerID int) (int, error) {
|
||||
}
|
||||
|
||||
func (a *App) buildAdviceResponse(stage string, playerID int, raw scoreAdviceModelResponse) ScoreAdviceResponse {
|
||||
advised := clampInt(raw.AdvisedScore, 0, 9999)
|
||||
advised := clampInt(raw.AdvisedScore, 0, 100)
|
||||
reason := strings.TrimSpace(raw.Reason)
|
||||
if reason == "" {
|
||||
reason = "AI estimated the score from visible impacts."
|
||||
|
||||
145
backend/api_docs.go
Normal file
145
backend/api_docs.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
//go:embed openapi/openapi.json
|
||||
var openAPISpecJSON string
|
||||
|
||||
func (a *App) handleOpenAPISpec(c echo.Context) error {
|
||||
return c.Blob(http.StatusOK, "application/json; charset=utf-8", []byte(openAPISpecJSON))
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIDocs(c echo.Context) error {
|
||||
html := fmt.Sprintf(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Shooting Event API Docs</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
.docs-top {
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #dde5f3;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.docs-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
color: #1b1f40;
|
||||
}
|
||||
.docs-help {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #4c5773;
|
||||
}
|
||||
.auth-panel {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.auth-panel input {
|
||||
height: 36px;
|
||||
border: 1px solid #cdd7eb;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.auth-panel button {
|
||||
height: 36px;
|
||||
border: 1px solid #20284f;
|
||||
background: #20284f;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auth-status {
|
||||
font-size: 13px;
|
||||
color: #38446b;
|
||||
}
|
||||
#swagger-ui {
|
||||
max-width: 1300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="docs-top">
|
||||
<h1 class="docs-title">Shooting Event OpenAPI Docs</h1>
|
||||
<p class="docs-help">
|
||||
Use <strong>Try it out</strong> for live testing. For admin endpoints: login with the form below,
|
||||
then your token is auto-attached to Swagger Authorize.
|
||||
</p>
|
||||
|
||||
<form id="auth-form" class="auth-panel">
|
||||
<input id="admin-user" type="text" value="datwyler" autocomplete="username" placeholder="Admin username" />
|
||||
<input id="admin-pass" type="password" value="datwyler" autocomplete="current-password" placeholder="Admin password" />
|
||||
<button type="submit">Login & Authorize</button>
|
||||
<span id="auth-status" class="auth-status"></span>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
const ui = SwaggerUIBundle({
|
||||
url: %q,
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
displayRequestDuration: true,
|
||||
persistAuthorization: true,
|
||||
docExpansion: 'list',
|
||||
tryItOutEnabled: true,
|
||||
});
|
||||
|
||||
async function loginAndAuthorize(event) {
|
||||
event.preventDefault();
|
||||
const status = document.getElementById('auth-status');
|
||||
status.textContent = 'Logging in...';
|
||||
|
||||
const username = document.getElementById('admin-user').value || '';
|
||||
const password = document.getElementById('admin-pass').value || '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || 'Login failed');
|
||||
}
|
||||
if (!payload.token) {
|
||||
throw new Error('Login succeeded but token was not returned');
|
||||
}
|
||||
|
||||
ui.preauthorizeApiKey('bearerAuth', payload.token);
|
||||
status.textContent = 'Authorized until ' + (payload.expiresAt || 'token expiry');
|
||||
} catch (error) {
|
||||
status.textContent = 'Auth failed: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('auth-form').addEventListener('submit', loginAndAuthorize);
|
||||
</script>
|
||||
</body>
|
||||
</html>`, "/api/openapi.json")
|
||||
|
||||
return c.HTML(http.StatusOK, html)
|
||||
}
|
||||
@@ -198,6 +198,6 @@ func scoreAdvicePrompt(stage string, currentScore int) string {
|
||||
return fmt.Sprintf(`Target scoring assistant.
|
||||
Stage: %s. Current score: %d.
|
||||
Return STRICT JSON only:
|
||||
{"advisedScore":<int 0..9999>,"reason":"<max 18 words>"}
|
||||
{"advisedScore":<int 0..100>,"reason":"<max 18 words>"}
|
||||
Do not add markdown or extra fields.`, stage, currentScore)
|
||||
}
|
||||
|
||||
@@ -310,8 +310,8 @@ func (a *App) handleUpdateScore(c echo.Context) error {
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
if req.Score < 0 || req.Score > 9999 {
|
||||
return writeError(c, http.StatusBadRequest, "score must be between 0 and 9999")
|
||||
if req.Score < 0 || req.Score > 100 {
|
||||
return writeError(c, http.StatusBadRequest, "score must be between 0 and 100")
|
||||
}
|
||||
|
||||
res, err := a.db.Exec(`
|
||||
|
||||
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) {
|
||||
api := e.Group("/api")
|
||||
api.GET("/docs", app.handleOpenAPIDocs)
|
||||
api.GET("/docs/", app.handleOpenAPIDocs)
|
||||
api.GET("/openapi.json", app.handleOpenAPISpec)
|
||||
|
||||
api.GET("/health", app.handleHealth)
|
||||
api.GET("/state", app.handleGetState)
|
||||
api.GET("/events", app.handleEvents)
|
||||
|
||||
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"
|
||||
:mode="mode"
|
||||
:language="language"
|
||||
:is-fullscreen="isFullscreen"
|
||||
:server-time="state.serverTime ? formatServerTime(state.serverTime) : ''"
|
||||
@change-mode="mode = $event"
|
||||
@change-language="setLanguage"
|
||||
@toggle-fullscreen="toggleFullscreen"
|
||||
/>
|
||||
|
||||
<main class="page-content">
|
||||
@@ -133,6 +135,7 @@
|
||||
:display-name="displayName"
|
||||
:secondary-name="secondaryName"
|
||||
:score-input-value="scoreInputValue"
|
||||
:score-input-invalid="scoreInputInvalid"
|
||||
:on-score-focus="onScoreFocus"
|
||||
:on-score-input="onScoreInput"
|
||||
:on-score-commit="onScoreCommit"
|
||||
@@ -222,6 +225,7 @@ const mode = ref('live')
|
||||
const viewTab = ref('groups')
|
||||
const adminTab = ref('players')
|
||||
const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar')
|
||||
const isFullscreen = ref(false)
|
||||
const state = ref(createInitialState())
|
||||
const primaryGroups = ref(loadPrimaryGroups(localStorage))
|
||||
|
||||
@@ -640,6 +644,11 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
onFullscreenChange()
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
document.addEventListener('webkitfullscreenchange', onFullscreenChange)
|
||||
document.addEventListener('mozfullscreenchange', onFullscreenChange)
|
||||
document.addEventListener('MSFullscreenChange', onFullscreenChange)
|
||||
document.documentElement.lang = language.value
|
||||
document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr'
|
||||
await fetchState()
|
||||
@@ -647,6 +656,10 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||
document.removeEventListener('webkitfullscreenchange', onFullscreenChange)
|
||||
document.removeEventListener('mozfullscreenchange', onFullscreenChange)
|
||||
document.removeEventListener('MSFullscreenChange', onFullscreenChange)
|
||||
stopLiveRotation()
|
||||
stopLiveMonitorRotation()
|
||||
stopRealtimeSync()
|
||||
@@ -662,6 +675,57 @@ function setLanguage(next) {
|
||||
if (next === 'ar' || next === 'en') language.value = next
|
||||
}
|
||||
|
||||
function getFullscreenElement() {
|
||||
return (
|
||||
document.fullscreenElement ||
|
||||
document.webkitFullscreenElement ||
|
||||
document.mozFullScreenElement ||
|
||||
document.msFullscreenElement ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function onFullscreenChange() {
|
||||
isFullscreen.value = Boolean(getFullscreenElement())
|
||||
}
|
||||
|
||||
function requestFullscreenFor(element) {
|
||||
if (!element) return Promise.resolve()
|
||||
const fn =
|
||||
element.requestFullscreen ||
|
||||
element.webkitRequestFullscreen ||
|
||||
element.mozRequestFullScreen ||
|
||||
element.msRequestFullscreen
|
||||
if (!fn) return Promise.resolve()
|
||||
const result = fn.call(element)
|
||||
if (result && typeof result.then === 'function') return result
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
function exitFullscreen() {
|
||||
const fn =
|
||||
document.exitFullscreen ||
|
||||
document.webkitExitFullscreen ||
|
||||
document.mozCancelFullScreen ||
|
||||
document.msExitFullscreen
|
||||
if (!fn) return Promise.resolve()
|
||||
const result = fn.call(document)
|
||||
if (result && typeof result.then === 'function') return result
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
async function toggleFullscreen() {
|
||||
try {
|
||||
if (getFullscreenElement()) {
|
||||
await exitFullscreen()
|
||||
return
|
||||
}
|
||||
await requestFullscreenFor(document.documentElement)
|
||||
} catch {
|
||||
// Ignore fullscreen errors, mostly caused by browser policy.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLiveActiveView(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'podium'])
|
||||
@@ -1209,7 +1273,7 @@ async function applySuggestedScore() {
|
||||
if (!scoreAdviceModal.advice) return
|
||||
const stage = scoreAdviceModal.stage
|
||||
const playerId = scoreAdviceModal.playerId
|
||||
const score = Number(scoreAdviceModal.advice.advisedScore || 0)
|
||||
const score = clampScoreValue(scoreAdviceModal.advice.advisedScore)
|
||||
await updateScoreValue(stage, playerId, score)
|
||||
scoreAdviceModal.currentScore = score
|
||||
closeScoreAdviceModal()
|
||||
@@ -1287,26 +1351,48 @@ function scoreDraftKey(stage, playerId) {
|
||||
return `${stage}:${playerId}`
|
||||
}
|
||||
|
||||
function clampScoreValue(value) {
|
||||
let score = Number(value)
|
||||
if (!Number.isFinite(score)) score = 0
|
||||
score = Math.trunc(score)
|
||||
if (score < 0) return 0
|
||||
if (score > 100) return 100
|
||||
return score
|
||||
}
|
||||
|
||||
function scoreInputValue(stage, playerId) {
|
||||
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
|
||||
if (draft) return draft.value
|
||||
return String(scoreFor(stage, playerId))
|
||||
}
|
||||
|
||||
function scoreInputInvalid(stage, playerId) {
|
||||
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
|
||||
return Boolean(draft?.invalid)
|
||||
}
|
||||
|
||||
function parseDraftScoreValue(value) {
|
||||
let score = Number(value === '' ? 0 : value)
|
||||
if (!Number.isFinite(score)) score = 0
|
||||
return Math.trunc(score)
|
||||
}
|
||||
|
||||
function onScoreFocus(stage, playerId) {
|
||||
const key = scoreDraftKey(stage, playerId)
|
||||
if (!scoreDrafts[key]) {
|
||||
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false }
|
||||
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
|
||||
}
|
||||
}
|
||||
|
||||
function onScoreInput(stage, playerId, event) {
|
||||
const key = scoreDraftKey(stage, playerId)
|
||||
const raw = String(event?.target?.value ?? '')
|
||||
const cleaned = raw.replace(/[^\d]/g, '').slice(0, 4)
|
||||
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false }
|
||||
scoreDrafts[key] = { ...current, value: cleaned }
|
||||
if (event?.target && event.target.value !== cleaned) event.target.value = cleaned
|
||||
const digits = raw.replace(/[^\d]/g, '').slice(0, 3)
|
||||
const parsed = parseDraftScoreValue(digits)
|
||||
const invalid = digits !== '' && (parsed < 0 || parsed > 100)
|
||||
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
|
||||
scoreDrafts[key] = { ...current, value: digits, invalid }
|
||||
if (event?.target && event.target.value !== digits) event.target.value = digits
|
||||
}
|
||||
|
||||
async function onScoreCommit(stage, playerId) {
|
||||
@@ -1314,11 +1400,11 @@ async function onScoreCommit(stage, playerId) {
|
||||
const draft = scoreDrafts[key]
|
||||
if (!draft || draft.committing) return
|
||||
|
||||
let score = Number(draft.value === '' ? 0 : draft.value)
|
||||
if (!Number.isFinite(score)) score = 0
|
||||
score = Math.trunc(score)
|
||||
const score = parseDraftScoreValue(draft.value)
|
||||
const invalid = score < 0 || score > 100
|
||||
|
||||
if (score < 0 || score > 9999) {
|
||||
if (invalid) {
|
||||
scoreDrafts[key] = { ...draft, invalid: true }
|
||||
showToast(t('messages.invalidScore'), 'error')
|
||||
return
|
||||
}
|
||||
@@ -1328,12 +1414,12 @@ async function onScoreCommit(stage, playerId) {
|
||||
return
|
||||
}
|
||||
|
||||
scoreDrafts[key] = { ...draft, committing: true }
|
||||
scoreDrafts[key] = { ...draft, invalid: false, committing: true }
|
||||
await updateScoreValue(stage, playerId, score, key)
|
||||
}
|
||||
|
||||
async function updateScoreValue(stage, playerId, score, draftKey = '') {
|
||||
if (score < 0 || score > 9999) {
|
||||
if (score < 0 || score > 100) {
|
||||
showToast(t('messages.invalidScore'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
:display-name="displayName"
|
||||
:secondary-name="secondaryName"
|
||||
:score-input-value="scoreInputValue"
|
||||
:score-input-invalid="scoreInputInvalid"
|
||||
:on-score-focus="onScoreFocus"
|
||||
:on-score-input="onScoreInput"
|
||||
:on-score-commit="onScoreCommit"
|
||||
@@ -139,6 +140,7 @@
|
||||
:display-name="displayName"
|
||||
:secondary-name="secondaryName"
|
||||
:score-input-value="scoreInputValue"
|
||||
:score-input-invalid="scoreInputInvalid"
|
||||
:on-score-focus="onScoreFocus"
|
||||
:on-score-input="onScoreInput"
|
||||
:on-score-commit="onScoreCommit"
|
||||
@@ -188,6 +190,7 @@
|
||||
:display-name="displayName"
|
||||
:secondary-name="secondaryName"
|
||||
:score-input-value="scoreInputValue"
|
||||
:score-input-invalid="scoreInputInvalid"
|
||||
:on-score-focus="onScoreFocus"
|
||||
:on-score-input="onScoreInput"
|
||||
:on-score-commit="onScoreCommit"
|
||||
@@ -224,6 +227,7 @@
|
||||
:display-name="displayName"
|
||||
:secondary-name="secondaryName"
|
||||
:score-input-value="scoreInputValue"
|
||||
:score-input-invalid="scoreInputInvalid"
|
||||
:on-score-focus="onScoreFocus"
|
||||
:on-score-input="onScoreInput"
|
||||
:on-score-commit="onScoreCommit"
|
||||
@@ -313,6 +317,7 @@ defineProps({
|
||||
displayName: { type: Function, required: true },
|
||||
secondaryName: { type: Function, required: true },
|
||||
scoreInputValue: { type: Function, required: true },
|
||||
scoreInputInvalid: { type: Function, required: true },
|
||||
onScoreFocus: { type: Function, required: true },
|
||||
onScoreInput: { type: Function, required: true },
|
||||
onScoreCommit: { type: Function, required: true },
|
||||
|
||||
@@ -16,6 +16,35 @@
|
||||
<span>{{ optionsLabel }}</span>
|
||||
<span class="control-toggle-meta">{{ modeShort }} · {{ languageShort }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="control-icon-btn"
|
||||
type="button"
|
||||
:class="{ active: isFullscreen }"
|
||||
:aria-label="fullscreenActionLabel"
|
||||
:title="fullscreenActionLabel"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
v-if="!isFullscreen"
|
||||
d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
v-else
|
||||
d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Transition name="controls-reveal">
|
||||
@@ -57,9 +86,10 @@ const props = defineProps({
|
||||
mode: { type: String, required: true },
|
||||
language: { type: String, required: true },
|
||||
serverTime: { type: String, default: '' },
|
||||
isFullscreen: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change-mode', 'change-language'])
|
||||
const emit = defineEmits(['change-mode', 'change-language', 'toggle-fullscreen'])
|
||||
|
||||
const controlsOpen = ref(false)
|
||||
const controlsRoot = ref(null)
|
||||
@@ -71,6 +101,7 @@ const modeShort = computed(() => {
|
||||
return props.language === 'ar' ? 'إدارة' : 'Admin'
|
||||
})
|
||||
const languageShort = computed(() => (props.language === 'ar' ? 'AR' : 'EN'))
|
||||
const fullscreenActionLabel = computed(() => (props.isFullscreen ? props.t('actions.exitFullscreen') : props.t('actions.enterFullscreen')))
|
||||
const modeStatusLabel = computed(() => {
|
||||
if (props.mode === 'view') return '● ' + props.t('labels.liveTracker')
|
||||
if (props.mode === 'live') return '● ' + props.t('liveMode')
|
||||
@@ -91,6 +122,10 @@ function changeLanguage(nextLanguage) {
|
||||
controlsOpen.value = false
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
emit('toggle-fullscreen')
|
||||
}
|
||||
|
||||
function onGlobalPointerDown(event) {
|
||||
if (!controlsOpen.value) return
|
||||
const root = controlsRoot.value
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<template>
|
||||
<section class="live-mode-shell">
|
||||
<div class="live-mode-overlay">
|
||||
<div class="live-mode-root">
|
||||
<section class="live-mode-shell">
|
||||
<div class="live-mode-overlay">
|
||||
<header class="live-mode-header">
|
||||
<h2>{{ t('sections.liveModeTitle') }}</h2>
|
||||
<p>{{ t('sections.liveModeSubtitle') }}</p>
|
||||
</header>
|
||||
|
||||
<div class="live-screen-head live-head-selection">
|
||||
<!-- <div class="live-screen-head live-head-selection">
|
||||
<span class="live-chip strong">{{ activeViewLabel }}</span>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="live-mode-grid single">
|
||||
<article v-if="activeView === 'group_live'" class="live-screen-card wide">
|
||||
@@ -256,7 +257,7 @@
|
||||
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
|
||||
</div>
|
||||
<div class="podium-medal-icon">🥈</div>
|
||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
|
||||
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[0])" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
|
||||
<div class="podium-score-wrap">
|
||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
|
||||
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
|
||||
@@ -268,7 +269,7 @@
|
||||
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
|
||||
</div>
|
||||
<div class="podium-medal-icon">🥇</div>
|
||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
|
||||
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[1])" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
|
||||
<div class="podium-score-wrap">
|
||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
|
||||
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
|
||||
@@ -280,7 +281,7 @@
|
||||
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
|
||||
</div>
|
||||
<div class="podium-medal-icon">🥉</div>
|
||||
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
|
||||
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[2])" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
|
||||
<div class="podium-score-wrap">
|
||||
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
|
||||
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
|
||||
@@ -289,8 +290,22 @@
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -41,11 +41,12 @@
|
||||
<td v-for="round in roundStages" :key="'cell-' + round.stage + '-' + row.playerId" class="multi-round-cell">
|
||||
<input
|
||||
class="score-input score-input-compact"
|
||||
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId) }"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min="0"
|
||||
max="9999"
|
||||
max="100"
|
||||
:value="scoreInputValue(round.stage, row.playerId)"
|
||||
@focus="onScoreFocus(round.stage, row.playerId)"
|
||||
@input="onScoreInput(round.stage, row.playerId, $event)"
|
||||
@@ -103,11 +104,12 @@
|
||||
<span class="score-label">{{ round.label }}</span>
|
||||
<input
|
||||
class="score-input"
|
||||
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId) }"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min="0"
|
||||
max="9999"
|
||||
max="100"
|
||||
:value="scoreInputValue(round.stage, row.playerId)"
|
||||
@focus="onScoreFocus(round.stage, row.playerId)"
|
||||
@input="onScoreInput(round.stage, row.playerId, $event)"
|
||||
@@ -164,6 +166,7 @@ const props = defineProps({
|
||||
displayName: { type: Function, required: true },
|
||||
secondaryName: { type: Function, required: true },
|
||||
scoreInputValue: { type: Function, required: true },
|
||||
scoreInputInvalid: { type: Function, required: true },
|
||||
onScoreFocus: { type: Function, required: true },
|
||||
onScoreInput: { type: Function, required: true },
|
||||
onScoreCommit: { type: Function, required: true },
|
||||
|
||||
@@ -40,11 +40,12 @@
|
||||
<td>
|
||||
<input
|
||||
class="score-input"
|
||||
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId) }"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min="0"
|
||||
max="9999"
|
||||
max="100"
|
||||
:value="scoreInputValue(stage, row.playerId)"
|
||||
@focus="onScoreFocus(stage, row.playerId)"
|
||||
@input="onScoreInput(stage, row.playerId, $event)"
|
||||
@@ -109,11 +110,12 @@
|
||||
<label class="score-label">{{ inputLabel }}</label>
|
||||
<input
|
||||
class="score-input"
|
||||
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId) }"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
min="0"
|
||||
max="9999"
|
||||
max="100"
|
||||
:value="scoreInputValue(stage, row.playerId)"
|
||||
@focus="onScoreFocus(stage, row.playerId)"
|
||||
@input="onScoreInput(stage, row.playerId, $event)"
|
||||
@@ -170,6 +172,7 @@ const props = defineProps({
|
||||
displayName: { type: Function, required: true },
|
||||
secondaryName: { type: Function, required: true },
|
||||
scoreInputValue: { type: Function, required: true },
|
||||
scoreInputInvalid: { type: Function, required: true },
|
||||
onScoreFocus: { type: Function, required: true },
|
||||
onScoreInput: { type: Function, required: true },
|
||||
onScoreCommit: { type: Function, required: true },
|
||||
|
||||
@@ -14,6 +14,7 @@ export const I18N = {
|
||||
liveTracker: 'LIVE TRACKER',
|
||||
mode: 'الوضع',
|
||||
language: 'اللغة',
|
||||
fullscreen: 'ملء الشاشة',
|
||||
lastSync: 'آخر مزامنة',
|
||||
loading: 'جاري تحميل بيانات البطولة...',
|
||||
players: 'لاعب',
|
||||
@@ -139,6 +140,8 @@ export const I18N = {
|
||||
resetPosition: 'إعادة الضبط',
|
||||
liveModeRotate: 'تدوير تلقائي',
|
||||
liveModeFixed: 'ثابت',
|
||||
enterFullscreen: 'دخول ملء الشاشة',
|
||||
exitFullscreen: 'الخروج من ملء الشاشة',
|
||||
},
|
||||
auth: {
|
||||
username: 'اسم المستخدم',
|
||||
@@ -166,7 +169,7 @@ export const I18N = {
|
||||
confirmDelete: 'هل تريد حذف اللاعب؟',
|
||||
confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟',
|
||||
resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟',
|
||||
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 9999.',
|
||||
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 100.',
|
||||
unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.',
|
||||
errorPrefix: 'حدث خطأ',
|
||||
noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.',
|
||||
@@ -190,6 +193,7 @@ export const I18N = {
|
||||
liveTracker: 'LIVE TRACKER',
|
||||
mode: 'Mode',
|
||||
language: 'Language',
|
||||
fullscreen: 'Fullscreen',
|
||||
lastSync: 'Last sync',
|
||||
loading: 'Loading tournament data...',
|
||||
players: 'Players',
|
||||
@@ -315,6 +319,8 @@ export const I18N = {
|
||||
resetPosition: 'Reset position',
|
||||
liveModeRotate: 'Auto rotate',
|
||||
liveModeFixed: 'Fixed',
|
||||
enterFullscreen: 'Enter Fullscreen',
|
||||
exitFullscreen: 'Exit Fullscreen',
|
||||
},
|
||||
auth: {
|
||||
username: 'Username',
|
||||
@@ -342,7 +348,7 @@ export const I18N = {
|
||||
confirmDelete: 'Delete this player?',
|
||||
confirmReset: 'Reset this stage scores?',
|
||||
resetProofPrompt: 'Also remove all proof images for this stage?',
|
||||
invalidScore: 'Score must be between 0 and 9999.',
|
||||
invalidScore: 'Score must be between 0 and 100.',
|
||||
unauthorized: 'Admin session expired. Please login again.',
|
||||
errorPrefix: 'Error',
|
||||
noGroupsConfigured: 'No primary groups configured.',
|
||||
|
||||
@@ -117,6 +117,8 @@ body {
|
||||
.control-toggle-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-toggle-btn {
|
||||
@@ -138,6 +140,35 @@ body {
|
||||
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 {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
color: #6c7a97;
|
||||
@@ -901,6 +932,23 @@ select.name-input {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1123,12 +1171,18 @@ select.name-input {
|
||||
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 {
|
||||
position: relative;
|
||||
min-height: 72vh;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background-image: url('/bg_live.png');
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
@@ -1139,12 +1193,12 @@ select.name-input {
|
||||
.live-mode-overlay {
|
||||
min-height: 72vh;
|
||||
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 {
|
||||
margin: 0;
|
||||
color: #f8d78b;
|
||||
color: rgba(240, 248, 255, 0.84);
|
||||
font-size: clamp(22px, 3vw, 34px);
|
||||
}
|
||||
|
||||
@@ -1167,7 +1221,7 @@ select.name-input {
|
||||
.live-screen-card {
|
||||
border: 1px solid rgba(200, 223, 255, 0.28);
|
||||
border-radius: 14px;
|
||||
background: rgba(8, 16, 38, 0.62);
|
||||
background: #1a1e3f;
|
||||
box-shadow: 0 8px 18px rgba(7, 19, 42, 0.18);
|
||||
backdrop-filter: blur(3px);
|
||||
padding: 10px;
|
||||
@@ -1186,7 +1240,7 @@ select.name-input {
|
||||
.live-focus-banner {
|
||||
border: 1px solid rgba(168, 205, 242, 0.4);
|
||||
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;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
@@ -1211,28 +1265,13 @@ select.name-input {
|
||||
text-shadow: 0 3px 10px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.live-focus-banner.focus-a .live-focus-title {
|
||||
color: #98c4ff;
|
||||
}
|
||||
|
||||
.live-focus-banner.focus-b .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-a .live-focus-title,
|
||||
.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-u .live-focus-title,
|
||||
.live-focus-banner.focus-final .live-focus-title {
|
||||
color: #f9e3a1;
|
||||
color: #d9e3f2;
|
||||
}
|
||||
|
||||
.live-screen-head {
|
||||
@@ -1245,6 +1284,10 @@ select.name-input {
|
||||
|
||||
.live-head-selection {
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.live-screen-head h3 {
|
||||
@@ -1293,6 +1336,56 @@ select.name-input {
|
||||
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 {
|
||||
min-width: 98px;
|
||||
}
|
||||
@@ -1376,7 +1469,7 @@ select.name-input {
|
||||
}
|
||||
|
||||
.live-score-table thead th {
|
||||
background: linear-gradient(90deg, #11254a 0%, #124665 100%);
|
||||
background: #171b3c;
|
||||
}
|
||||
|
||||
.live-screen-card .score-table td {
|
||||
@@ -1391,49 +1484,103 @@ select.name-input {
|
||||
|
||||
.live-screen-card .table-wrap {
|
||||
border-color: rgba(158, 186, 216, 0.28);
|
||||
background: rgba(11, 24, 49, 0.65);
|
||||
background: #171b3c;
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin-top: 60px;
|
||||
height: 390px;
|
||||
margin-top: 72px;
|
||||
min-height: 500px;
|
||||
gap: 22px;
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.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);
|
||||
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;
|
||||
max-width: 330px;
|
||||
border-radius: 22px 22px 0 0;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-1 {
|
||||
border-top: 8px solid #e7c45f;
|
||||
background: linear-gradient(180deg, rgba(48, 38, 15, 0.95) 0%, rgba(18, 24, 48, 0.96) 100%);
|
||||
height: 330px;
|
||||
border-top: 10px solid rgba(231, 196, 95, 0.4);
|
||||
background: #1a1e3f;
|
||||
height: 430px;
|
||||
box-shadow: 0 22px 44px rgba(231, 196, 95, 0.15);
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-2 {
|
||||
border-top: 8px solid #b8c3d7;
|
||||
background: linear-gradient(180deg, rgba(37, 44, 62, 0.95) 0%, rgba(12, 20, 43, 0.96) 100%);
|
||||
height: 260px;
|
||||
border-top: 10px solid rgba(184, 195, 215, 0.4);
|
||||
background: #1a1e3f;
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-3 {
|
||||
border-top: 8px solid #cf8b45;
|
||||
background: linear-gradient(180deg, rgba(59, 36, 23, 0.95) 0%, rgba(14, 22, 45, 0.96) 100%);
|
||||
height: 240px;
|
||||
border-top: 10px solid rgba(207, 139, 69, 0.4);
|
||||
background: #1a1e3f;
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
@@ -1441,12 +1588,29 @@ select.name-input {
|
||||
color: #ffd888;
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
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 {
|
||||
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 {
|
||||
background: #f0f2f8;
|
||||
}
|
||||
@@ -2111,10 +2275,15 @@ select.name-input {
|
||||
}
|
||||
|
||||
.control-toggle-btn {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.control-icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
grid-auto-columns: minmax(126px, 1fr);
|
||||
}
|
||||
@@ -2170,14 +2339,19 @@ select.name-input {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.live-mode-root {
|
||||
--live-footer-height: 62px;
|
||||
--live-footer-offset: 10px;
|
||||
}
|
||||
|
||||
.live-mode-shell {
|
||||
border-radius: 12px;
|
||||
min-height: 0;
|
||||
min-height: 70dvh;
|
||||
}
|
||||
|
||||
.live-mode-overlay {
|
||||
padding: 10px;
|
||||
min-height: 0;
|
||||
min-height: 70dvh;
|
||||
}
|
||||
|
||||
.live-mode-grid {
|
||||
@@ -2193,6 +2367,23 @@ select.name-input {
|
||||
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 {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -2229,6 +2420,36 @@ select.name-input {
|
||||
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 {
|
||||
width: 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