feat: refine current match live feed
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# TASK-156 - Current match realtime killfeed and home buttons layout
|
# TASK-156 - Current match realtime killfeed and home buttons layout
|
||||||
|
|
||||||
Status: pending
|
Status: done
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@@ -178,7 +178,23 @@ The current-match kill feed behaves like a compact real-time combat overlay, and
|
|||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
Document the validation performed, notable decisions, and any follow-up task that should be created instead of expanding this task.
|
- Kept the existing `event_id`-based killfeed refresh path and changed only its
|
||||||
|
rendering surface: the capped 15-event panel now reads as a bounded combat
|
||||||
|
screen with three desktop columns, while smaller breakpoints remain bounded.
|
||||||
|
- Removed the home server-card public-scoreboard action from the hydrated markup
|
||||||
|
and left the remaining `Historico` / `Partida actual` actions aligned by the
|
||||||
|
existing status-column layout.
|
||||||
|
- Validated with:
|
||||||
|
- `node --check frontend/assets/js/partida-actual.js`
|
||||||
|
- `node --check frontend/assets/js/main.js`
|
||||||
|
- `git diff --check`
|
||||||
|
- `docker compose up -d --build frontend`
|
||||||
|
- live endpoint checks for `/api/current-match/kills` on Comunidad Hispana
|
||||||
|
`#01` and `#02`
|
||||||
|
- rendered Chrome headless screenshots of the home page, current-match `#01`
|
||||||
|
desktop view and current-match `#02` mobile empty-state view
|
||||||
|
- The in-app Browser automation runtime was not callable after tool discovery,
|
||||||
|
so rendered validation used local headless Chrome against the task URLs.
|
||||||
|
|
||||||
## Change Budget
|
## Change Budget
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# TASK-157 - Current match player team colors
|
# TASK-157 - Current match player team colors
|
||||||
|
|
||||||
Status: pending
|
Status: done
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@@ -160,7 +160,35 @@ The current-match player/stat section uses team-aware colors consistent with the
|
|||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
Document the validation performed, notable decisions, and any follow-up task that should be created instead of expanding this task.
|
- RCA found no backend contract gap: a live
|
||||||
|
`/api/current-match/players?server=comunidad-hispana-01` response observed
|
||||||
|
before the validation rebuild already exposed AdminLog-derived `team` values
|
||||||
|
such as `Allies` and `Axis`; unknown values remain handled by the existing
|
||||||
|
frontend neutral path.
|
||||||
|
- Reused the historical detail player row and team badge CSS modifiers from
|
||||||
|
`historico-scoreboard-detail.css` in the current-match player table instead
|
||||||
|
of adding a parallel style system.
|
||||||
|
- Included `team` in the visible player-table signature so a future team update
|
||||||
|
can rerender an already-visible row without changing its stats.
|
||||||
|
- Validated with:
|
||||||
|
- `node --check frontend/assets/js/partida-actual.js`
|
||||||
|
- `git diff --check`
|
||||||
|
- `docker compose up -d --build frontend`
|
||||||
|
- live `/api/current-match/players` checks on Comunidad Hispana `#01` and
|
||||||
|
`#02`
|
||||||
|
- rendered Chrome headless current-match validation for the available player
|
||||||
|
empty state after the backend restart
|
||||||
|
- served-script check confirming the historical row and badge modifiers are
|
||||||
|
present in `frontend/assets/js/partida-actual.js`
|
||||||
|
- The final rendered validation window had no current player rows after the
|
||||||
|
backend rebuild, so known-team colors were validated from the observed live
|
||||||
|
payload contract plus the reused historical class mapping rather than from a
|
||||||
|
live colored-row screenshot.
|
||||||
|
- Repository-level follow-up validation ran
|
||||||
|
`powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`.
|
||||||
|
It returned exit code `0`, but its output also printed a historical SQLite
|
||||||
|
`database disk image is malformed` traceback after the pass banner; that
|
||||||
|
historical storage issue is outside this frontend task scope.
|
||||||
|
|
||||||
## Change Budget
|
## Change Budget
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-158
|
id: TASK-158
|
||||||
title: Current match realtime transport
|
title: Current match realtime transport
|
||||||
status: pending
|
status: done
|
||||||
type: backend
|
type: backend
|
||||||
team: Backend Senior
|
team: Backend Senior
|
||||||
supporting_teams: [Frontend Senior]
|
supporting_teams: [Frontend Senior]
|
||||||
@@ -160,7 +160,31 @@ The current-match kill feed updates in near-real-time, without depending on the
|
|||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
Document the RCA, transport decision, validation performed, load/freshness limitations, and any follow-up task that should be created instead of expanding this task.
|
RCA:
|
||||||
|
|
||||||
|
- The current page used one `30s` interval in `frontend/assets/js/partida-actual.js` for current-match metadata, player stats and the kill feed together.
|
||||||
|
- The kill feed read path is already safe for higher-frequency reads because it queries normalized persisted `rcon_admin_log_events` rows through `/api/current-match/kills`; it does not query RCON from the browser or expose raw AdminLog lines.
|
||||||
|
- Current match metadata can still call live RCON through the existing payload path, and player stats aggregate the kill feed read model, so those surfaces remain on the slower refresh path.
|
||||||
|
|
||||||
|
Transport decision:
|
||||||
|
|
||||||
|
- Implemented incremental short polling for the kill feed instead of SSE. The backend is still the standard-library JSON route stack, while the existing persisted read model can cheaply support a cursor.
|
||||||
|
- `/api/current-match/kills` now accepts `since_event_id` plus the existing `server` and `limit` inputs. Trusted server validation remains in `routes.py`.
|
||||||
|
- The frontend polls kill-feed rows every `1500ms`, sends the latest event id cursor after the first load, deduplicates by `event_id`, caps the in-memory visible buffer and avoids overlapping kill-feed requests. Metadata and player stats keep the existing `30s` refresh cadence.
|
||||||
|
|
||||||
|
Validation performed:
|
||||||
|
|
||||||
|
- `python -m compileall backend/app`
|
||||||
|
- `node --check frontend/assets/js/partida-actual.js`
|
||||||
|
- `git diff --check`
|
||||||
|
- Ran a direct Python smoke check for `list_current_match_kill_feed(..., since_event_id=...)` with temporary AdminLog rows after the focused pytest command could not run.
|
||||||
|
- Attempted focused backend tests with `python -m pytest backend/tests/test_current_match_payload.py backend/tests/test_rcon_admin_log_storage.py`; this environment failed with `No module named pytest`.
|
||||||
|
- Reviewed `git diff --name-only`; task-owned code changes are limited to the allowed current-match backend/frontend files plus the focused storage test and this task transition. The worktree already contained frontend and prior task changes from `TASK-156`/`TASK-157`.
|
||||||
|
|
||||||
|
Load and freshness limit:
|
||||||
|
|
||||||
|
- Browser polling now reads persisted AdminLog rows rather than issuing per-browser RCON calls.
|
||||||
|
- New kill visibility is still bounded by how quickly `app.rcon_historical_worker` or manual AdminLog ingestion persists fresh AdminLog rows. The current documented worker example is a `120s` loop, so operations may need a follow-up cadence decision if that persisted source is not fresh enough for the desired live screen.
|
||||||
|
|
||||||
## Change Budget
|
## Change Budget
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-159
|
id: TASK-159
|
||||||
title: Current match feed rollback and weapon icons
|
title: Current match feed rollback and weapon icons
|
||||||
status: pending
|
status: done
|
||||||
type: frontend
|
type: frontend
|
||||||
team: Frontend Senior
|
team: Frontend Senior
|
||||||
supporting_teams: [Disenador grafico, Experto en interfaz]
|
supporting_teams: [Disenador grafico, Experto en interfaz]
|
||||||
@@ -162,7 +162,24 @@ The current-match feed returns to the previous preferred live-feed visual direct
|
|||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
Document the visual RCA, icon discovery/mapping decision, validation performed, and any follow-up task that should be created instead of expanding this task.
|
Visual RCA:
|
||||||
|
|
||||||
|
- Git history before the compact feed change showed a vertical `historical-match-card` list with one readable event per row/card. The rejected version introduced a fixed multi-column killfeed surface that reads like a grid/table.
|
||||||
|
- The feed was refactored back to a vertical card-like list while keeping the current live buffer, deduplication and no-flicker signature guard.
|
||||||
|
|
||||||
|
Weapon icon decision:
|
||||||
|
|
||||||
|
- Reused local assets already present under `frontend/assets/img/weapons/`.
|
||||||
|
- `partida-actual.js` maps normalized AdminLog weapon names for `M1 GARAND`, `MP40`, `M1A1 THOMPSON`, `GEWEHR 43`, `MG42` and aliases to local PNGs.
|
||||||
|
- Unknown/unmapped weapons keep their label for tooltip/accessibility and render a compact local fallback marker instead of hotlinking assets.
|
||||||
|
|
||||||
|
Validation performed:
|
||||||
|
|
||||||
|
- `node --check frontend/assets/js/partida-actual.js`
|
||||||
|
- `git diff --check`
|
||||||
|
- `docker compose build frontend`
|
||||||
|
- Reviewed the history diff for the prior accepted feed direction and inspected local weapon asset filenames.
|
||||||
|
- Rendered Browser verification was attempted through the required Browser workflow but blocked because the Browser JavaScript execution tool was not exposed in this session after tool discovery.
|
||||||
|
|
||||||
## Change Budget
|
## Change Budget
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: TASK-160
|
id: TASK-160
|
||||||
title: Home server card bottom actions
|
title: Home server card bottom actions
|
||||||
status: pending
|
status: done
|
||||||
type: frontend
|
type: frontend
|
||||||
team: Frontend Senior
|
team: Frontend Senior
|
||||||
supporting_teams: [Experto en interfaz]
|
supporting_teams: [Experto en interfaz]
|
||||||
@@ -143,7 +143,19 @@ The home server cards have a clean structural layout with only Historico and Par
|
|||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
Document the structural rendering cleanup, validation performed, and any follow-up task that should be created instead of expanding this task.
|
Structural cleanup:
|
||||||
|
|
||||||
|
- Home server card actions now live in a dedicated bottom row beside the map quickfact rather than inside the status column.
|
||||||
|
- The rendered card action markup keeps only `Historico` and `Partida actual`; trusted URL mappings stay intact for those actions.
|
||||||
|
- Removed the inline server-card layout workaround from `frontend/index.html` and moved the bottom-row layout rules into `frontend/assets/css/styles.css`, with a stacked narrow layout.
|
||||||
|
|
||||||
|
Validation performed:
|
||||||
|
|
||||||
|
- `node --check frontend/assets/js/main.js`
|
||||||
|
- `git diff --check`
|
||||||
|
- `docker compose build frontend`
|
||||||
|
- Checked the existing local frontend and backend endpoints were serving on `127.0.0.1:8080` and `127.0.0.1:8000`.
|
||||||
|
- Rendered Browser verification was attempted through the required Browser workflow but blocked because the Browser JavaScript execution tool was not exposed in this session after tool discovery.
|
||||||
|
|
||||||
## Change Budget
|
## Change Budget
|
||||||
|
|
||||||
@@ -354,12 +354,17 @@ def build_current_match_kill_feed_payload(
|
|||||||
*,
|
*,
|
||||||
server_slug: str,
|
server_slug: str,
|
||||||
limit: int = 30,
|
limit: int = 30,
|
||||||
|
since_event_id: str | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return normalized AdminLog kill rows for one trusted current-match page."""
|
"""Return normalized AdminLog kill rows for one trusted current-match page."""
|
||||||
origin = get_trusted_public_scoreboard_origin(server_slug)
|
origin = get_trusted_public_scoreboard_origin(server_slug)
|
||||||
if origin is None:
|
if origin is None:
|
||||||
raise ValueError("Unsupported current match server.")
|
raise ValueError("Unsupported current match server.")
|
||||||
feed = list_current_match_kill_feed(server_key=origin.slug, limit=limit)
|
feed = list_current_match_kill_feed(
|
||||||
|
server_key=origin.slug,
|
||||||
|
limit=limit,
|
||||||
|
since_event_id=since_event_id,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"data": {
|
"data": {
|
||||||
|
|||||||
@@ -337,11 +337,13 @@ def list_current_match_kill_feed(
|
|||||||
*,
|
*,
|
||||||
server_key: str,
|
server_key: str,
|
||||||
limit: int = 30,
|
limit: int = 30,
|
||||||
|
since_event_id: str | None = None,
|
||||||
db_path: Path | None = None,
|
db_path: Path | None = None,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
"""Return safe recent kill rows for one AdminLog server window."""
|
"""Return safe recent kill rows for one AdminLog server window."""
|
||||||
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
||||||
|
since_row_id = _parse_current_match_event_row_id(since_event_id)
|
||||||
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||||
from .postgres_rcon_storage import connect_postgres_compat
|
from .postgres_rcon_storage import connect_postgres_compat
|
||||||
|
|
||||||
@@ -377,10 +379,11 @@ def list_current_match_kill_feed(
|
|||||||
FROM rcon_admin_log_events
|
FROM rcon_admin_log_events
|
||||||
WHERE (target_key = ? OR external_server_id = ?)
|
WHERE (target_key = ? OR external_server_id = ?)
|
||||||
AND event_type = 'kill'
|
AND event_type = 'kill'
|
||||||
|
AND (? IS NULL OR id > ?)
|
||||||
ORDER BY server_time DESC, id DESC
|
ORDER BY server_time DESC, id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(server_key, server_key, limit),
|
(server_key, server_key, since_row_id, since_row_id, limit),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
scope = "recent-admin-log-window"
|
scope = "recent-admin-log-window"
|
||||||
confidence = "partial"
|
confidence = "partial"
|
||||||
@@ -393,10 +396,11 @@ def list_current_match_kill_feed(
|
|||||||
WHERE (target_key = ? OR external_server_id = ?)
|
WHERE (target_key = ? OR external_server_id = ?)
|
||||||
AND event_type = 'kill'
|
AND event_type = 'kill'
|
||||||
AND server_time >= ?
|
AND server_time >= ?
|
||||||
|
AND (? IS NULL OR id > ?)
|
||||||
ORDER BY server_time DESC, id DESC
|
ORDER BY server_time DESC, id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
(server_key, server_key, open_start_time, limit),
|
(server_key, server_key, open_start_time, since_row_id, since_row_id, limit),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
scope = "open-admin-log-match-window"
|
scope = "open-admin-log-match-window"
|
||||||
confidence = "admin-log-boundary"
|
confidence = "admin-log-boundary"
|
||||||
@@ -613,6 +617,17 @@ def _serialize_kill_feed_row(row: Mapping[str, object]) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_current_match_event_row_id(value: object) -> int | None:
|
||||||
|
prefix, separator, row_id = str(value or "").rpartition(":")
|
||||||
|
if separator != ":" or not prefix.startswith("rcon-admin-log:"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = int(row_id)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return parsed if parsed > 0 else None
|
||||||
|
|
||||||
|
|
||||||
def _safe_event_field(value: object) -> str | None:
|
def _safe_event_field(value: object) -> str | None:
|
||||||
normalized = str(value or "").strip()
|
normalized = str(value or "").strip()
|
||||||
return normalized or None
|
return normalized or None
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
|||||||
limit = _parse_limit(parsed.query)
|
limit = _parse_limit(parsed.query)
|
||||||
if limit is None:
|
if limit is None:
|
||||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Invalid limit parameter")
|
||||||
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
params = parse_qs(parsed.query)
|
||||||
|
server_slug = params.get("server", [None])[0]
|
||||||
if not server_slug:
|
if not server_slug:
|
||||||
return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required")
|
return HTTPStatus.BAD_REQUEST, build_error_payload("Server parameter is required")
|
||||||
if get_trusted_public_scoreboard_origin(server_slug) is None:
|
if get_trusted_public_scoreboard_origin(server_slug) is None:
|
||||||
@@ -84,6 +85,7 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
|||||||
return HTTPStatus.OK, build_current_match_kill_feed_payload(
|
return HTTPStatus.OK, build_current_match_kill_feed_payload(
|
||||||
server_slug=server_slug,
|
server_slug=server_slug,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
since_event_id=params.get("since_event_id", [None])[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
if parsed.path == "/api/current-match/players":
|
if parsed.path == "/api/current-match/players":
|
||||||
|
|||||||
@@ -368,3 +368,40 @@ def test_current_match_kill_feed_marks_fresh_recent_fallback_rows_partial(tmp_pa
|
|||||||
assert feed["stale_events_filtered"] == 0
|
assert feed["stale_events_filtered"] == 0
|
||||||
assert [item["killer_name"] for item in feed["items"]] == ["Fresh Killer"]
|
assert [item["killer_name"] for item in feed["items"]] == ["Fresh Killer"]
|
||||||
gc.collect()
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_match_kill_feed_filters_rows_before_incremental_cursor(tmp_path):
|
||||||
|
db_path = tmp_path / "admin_log.sqlite3"
|
||||||
|
persist_rcon_admin_log_entries(
|
||||||
|
target=TARGET,
|
||||||
|
entries=[
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-21T10:00:00Z",
|
||||||
|
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-21T10:01:00Z",
|
||||||
|
"message": (
|
||||||
|
"[2:00 min (120)] KILL: First Killer(Allies/steam-first) -> "
|
||||||
|
"First Victim(Axis/steam-first-victim) with M1 GARAND"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": "2026-05-21T10:02:00Z",
|
||||||
|
"message": (
|
||||||
|
"[3:00 min (140)] KILL: Next Killer(Axis/steam-next) -> "
|
||||||
|
"Next Victim(Allies/steam-next-victim) with MP40"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
feed = list_current_match_kill_feed(
|
||||||
|
server_key="test-rcon-target",
|
||||||
|
db_path=db_path,
|
||||||
|
since_event_id="rcon-admin-log:test-rcon-target:2",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [item["killer_name"] for item in feed["items"]] == ["Next Killer"]
|
||||||
|
gc.collect()
|
||||||
|
|||||||
@@ -591,50 +591,41 @@
|
|||||||
|
|
||||||
.current-match-killfeed {
|
.current-match-killfeed {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
gap: 10px;
|
||||||
grid-template-rows: repeat(5, minmax(0, 1fr));
|
max-height: 760px;
|
||||||
grid-auto-columns: minmax(0, 1fr);
|
padding: 4px 0 0;
|
||||||
gap: 10px 12px;
|
|
||||||
min-height: 250px;
|
|
||||||
max-height: 320px;
|
|
||||||
padding: 16px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(210, 182, 118, 0.26);
|
|
||||||
border-radius: 8px;
|
|
||||||
background:
|
|
||||||
linear-gradient(90deg, rgba(210, 182, 118, 0.08) 1px, transparent 1px),
|
|
||||||
linear-gradient(180deg, rgba(183, 201, 125, 0.06) 1px, transparent 1px),
|
|
||||||
linear-gradient(180deg, rgba(12, 18, 11, 0.96), rgba(7, 10, 7, 0.98));
|
|
||||||
background-size: 33.333% 100%, 100% 62px, auto;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
|
||||||
0 18px 38px rgba(0, 0, 0, 0.22);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-match-killfeed__row {
|
.current-match-killfeed__row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) minmax(102px, 150px) minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
min-height: 38px;
|
min-height: 56px;
|
||||||
padding: 7px 9px;
|
padding: 12px 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(159, 168, 141, 0.16);
|
border: 1px solid rgba(159, 168, 141, 0.18);
|
||||||
|
border-left: 3px solid rgba(183, 201, 125, 0.34);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: rgba(24, 31, 21, 0.86);
|
background:
|
||||||
|
linear-gradient(135deg, rgba(183, 201, 125, 0.08), transparent 44%),
|
||||||
|
rgba(14, 19, 12, 0.84);
|
||||||
animation: current-match-killfeed-enter 180ms ease-out;
|
animation: current-match-killfeed-enter 180ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-match-killfeed__row.is-teamkill {
|
.current-match-killfeed__row.is-teamkill {
|
||||||
border-color: rgba(210, 182, 118, 0.42);
|
border-left-color: rgba(210, 182, 118, 0.64);
|
||||||
background: rgba(73, 49, 27, 0.72);
|
background:
|
||||||
|
linear-gradient(135deg, rgba(210, 182, 118, 0.12), transparent 48%),
|
||||||
|
rgba(73, 49, 27, 0.42);
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-match-killfeed__player {
|
.current-match-killfeed__player {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 0.84rem;
|
font-size: 0.94rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -649,20 +640,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.current-match-killfeed__weapon {
|
.current-match-killfeed__weapon {
|
||||||
display: inline-flex;
|
display: grid;
|
||||||
|
grid-template-rows: 34px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 5px;
|
min-width: 102px;
|
||||||
min-width: 34px;
|
min-height: 40px;
|
||||||
min-height: 24px;
|
padding: 2px 8px;
|
||||||
padding: 0 6px;
|
|
||||||
border: 1px solid rgba(183, 201, 125, 0.24);
|
border: 1px solid rgba(183, 201, 125, 0.24);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: rgba(7, 10, 7, 0.8);
|
background: rgba(7, 10, 7, 0.8);
|
||||||
font-size: 0.68rem;
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__weapon-icon {
|
||||||
|
display: block;
|
||||||
|
width: min(100%, 116px);
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.72));
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__weapon-fallback {
|
||||||
|
display: grid;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid rgba(210, 182, 118, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--accent-warm);
|
||||||
|
font-size: 1rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
line-height: 1;
|
}
|
||||||
|
|
||||||
|
.current-match-killfeed__weapon-fallback[hidden] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-match-killfeed__weapon em {
|
.current-match-killfeed__weapon em {
|
||||||
@@ -920,14 +932,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.current-match-killfeed {
|
.current-match-killfeed {
|
||||||
grid-template-rows: repeat(8, minmax(0, 1fr));
|
max-height: none;
|
||||||
min-height: 312px;
|
|
||||||
max-height: 380px;
|
|
||||||
background-size: 50% 100%, 100% 48px, auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-match-killfeed__row {
|
.current-match-killfeed__row {
|
||||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(96px, 128px) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.current-match-killfeed__teamkill {
|
.current-match-killfeed__teamkill {
|
||||||
@@ -937,12 +946,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.current-match-killfeed {
|
.current-match-killfeed__row {
|
||||||
grid-auto-flow: row;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: none;
|
gap: 8px;
|
||||||
min-height: 0;
|
}
|
||||||
max-height: none;
|
|
||||||
background-size: 100% 48px, 100% 48px, auto;
|
.current-match-killfeed__weapon {
|
||||||
|
justify-self: start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.server-card__top--stats {
|
.server-card__top--stats {
|
||||||
align-items: stretch;
|
align-items: flex-start;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,9 +669,10 @@ h2 {
|
|||||||
|
|
||||||
.server-card__actions {
|
.server-card__actions {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: flex-end;
|
||||||
|
align-self: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
@@ -680,7 +681,7 @@ h2 {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
min-width: 136px;
|
min-width: 136px;
|
||||||
@@ -742,6 +743,14 @@ h2 {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-card__bottom {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 280px) minmax(272px, 1fr);
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.server-card__quickfact {
|
.server-card__quickfact {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
@@ -1019,13 +1028,17 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.server-card__actions {
|
.server-card__actions {
|
||||||
justify-content: start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-card__quickfacts {
|
.server-card__quickfacts {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-card__bottom {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.clan-card__brand {
|
.clan-card__brand {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
justify-items: start;
|
justify-items: start;
|
||||||
|
|||||||
@@ -297,10 +297,12 @@ function renderServerStatsCard(server) {
|
|||||||
<div class="server-card__status-column">
|
<div class="server-card__status-column">
|
||||||
<span class="server-state ${stateClass}">${escapeHtml(statusLabel)}</span>
|
<span class="server-state ${stateClass}">${escapeHtml(statusLabel)}</span>
|
||||||
<p class="server-card__population">${escapeHtml(`${players} / ${maxPlayers}`)}</p>
|
<p class="server-card__population">${escapeHtml(`${players} / ${maxPlayers}`)}</p>
|
||||||
${actionMarkup}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${quickFacts}
|
<div class="server-card__bottom">
|
||||||
|
${quickFacts}
|
||||||
|
${actionMarkup}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -342,16 +344,8 @@ function renderServerAction(server) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="server-card__actions">
|
<div class="server-card__actions">
|
||||||
<a
|
|
||||||
class="server-action-link"
|
|
||||||
href="${escapeHtml(actions.publicScoreboardUrl)}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Scoreboard publico
|
|
||||||
</a>
|
|
||||||
<a class="server-action-link" href="${escapeHtml(actions.historicalUrl)}">
|
<a class="server-action-link" href="${escapeHtml(actions.historicalUrl)}">
|
||||||
Nuestro historico
|
Historico
|
||||||
</a>
|
</a>
|
||||||
<a class="server-action-link" href="${escapeHtml(actions.currentMatchUrl)}">
|
<a class="server-action-link" href="${escapeHtml(actions.currentMatchUrl)}">
|
||||||
Partida actual
|
Partida actual
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const CURRENT_MATCH_POLL_INTERVAL_MS = 30 * 1000;
|
const CURRENT_MATCH_POLL_INTERVAL_MS = 30 * 1000;
|
||||||
|
const CURRENT_MATCH_KILL_FEED_POLL_INTERVAL_MS = 1500;
|
||||||
const CURRENT_MATCH_SERVERS = Object.freeze({
|
const CURRENT_MATCH_SERVERS = Object.freeze({
|
||||||
"comunidad-hispana-01": "Comunidad Hispana #01",
|
"comunidad-hispana-01": "Comunidad Hispana #01",
|
||||||
"comunidad-hispana-02": "Comunidad Hispana #02",
|
"comunidad-hispana-02": "Comunidad Hispana #02",
|
||||||
@@ -9,10 +10,14 @@ const CURRENT_MATCH_SCOREBOARDS = Object.freeze({
|
|||||||
});
|
});
|
||||||
const CURRENT_MATCH_KILL_FEED_LIMIT = 15;
|
const CURRENT_MATCH_KILL_FEED_LIMIT = 15;
|
||||||
const CURRENT_MATCH_WEAPONS = Object.freeze({
|
const CURRENT_MATCH_WEAPONS = Object.freeze({
|
||||||
"m1 garand": { label: "M1 Garand", glyph: "M1" },
|
"m1 garand": { label: "M1 Garand", icon: "m1_garand.PNG" },
|
||||||
mp40: { label: "MP40", glyph: "MP" },
|
mp40: { label: "MP40", icon: "mp40.PNG" },
|
||||||
"m1a1 thompson": { label: "M1A1 Thompson", glyph: "TH" },
|
"m1a1 thompson": { label: "M1A1 Thompson", icon: "thompson.PNG" },
|
||||||
unknown: { label: "Arma desconocida", glyph: "?" },
|
thompson: { label: "M1A1 Thompson", icon: "thompson.PNG" },
|
||||||
|
"gewehr 43": { label: "Gewehr 43", icon: "gewehr.PNG" },
|
||||||
|
g43: { label: "Gewehr 43", icon: "gewehr.PNG" },
|
||||||
|
mg42: { label: "MG42", icon: "mg42.PNG" },
|
||||||
|
unknown: { label: "Arma desconocida", icon: "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
@@ -43,27 +48,43 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`;
|
nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`;
|
||||||
const killFeedState = initializeKillFeed(nodes);
|
const killFeedState = initializeKillFeed(nodes);
|
||||||
const playerStatsState = initializePlayerStats(nodes);
|
const playerStatsState = initializePlayerStats(nodes);
|
||||||
let refreshInFlight = false;
|
let matchRefreshInFlight = false;
|
||||||
const refresh = async () => {
|
const refreshMatchPanels = async () => {
|
||||||
if (refreshInFlight) {
|
if (matchRefreshInFlight) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
refreshInFlight = true;
|
matchRefreshInFlight = true;
|
||||||
try {
|
try {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }),
|
loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }),
|
||||||
loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }),
|
|
||||||
loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }),
|
loadPlayerStats({ backendBaseUrl, serverSlug, nodes, playerStatsState }),
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
refreshInFlight = false;
|
matchRefreshInFlight = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
void refresh();
|
let killFeedRefreshInFlight = false;
|
||||||
|
const refreshKillFeed = async () => {
|
||||||
|
if (killFeedRefreshInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
killFeedRefreshInFlight = true;
|
||||||
|
try {
|
||||||
|
await loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState });
|
||||||
|
} finally {
|
||||||
|
killFeedRefreshInFlight = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void refreshMatchPanels();
|
||||||
|
void refreshKillFeed();
|
||||||
window.setInterval(() => {
|
window.setInterval(() => {
|
||||||
void refresh();
|
void refreshMatchPanels();
|
||||||
}, CURRENT_MATCH_POLL_INTERVAL_MS);
|
}, CURRENT_MATCH_POLL_INTERVAL_MS);
|
||||||
|
window.setInterval(() => {
|
||||||
|
void refreshKillFeed();
|
||||||
|
}, CURRENT_MATCH_KILL_FEED_POLL_INTERVAL_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) {
|
async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) {
|
||||||
@@ -80,8 +101,11 @@ async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) {
|
|||||||
|
|
||||||
async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) {
|
async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) {
|
||||||
try {
|
try {
|
||||||
|
const cursor = killFeedState.latestEventId
|
||||||
|
? `&since_event_id=${encodeURIComponent(killFeedState.latestEventId)}`
|
||||||
|
: "";
|
||||||
const payload = await fetchJson(
|
const payload = await fetchJson(
|
||||||
`${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=${CURRENT_MATCH_KILL_FEED_LIMIT}`,
|
`${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=${CURRENT_MATCH_KILL_FEED_LIMIT}${cursor}`,
|
||||||
);
|
);
|
||||||
renderKillFeed(payload?.data || {}, nodes, killFeedState);
|
renderKillFeed(payload?.data || {}, nodes, killFeedState);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -108,7 +132,7 @@ function renderCurrentMatch(data, nodes) {
|
|||||||
nodes.title.textContent = mapName;
|
nodes.title.textContent = mapName;
|
||||||
nodes.summary.textContent = serverName;
|
nodes.summary.textContent = serverName;
|
||||||
nodes.note.textContent = data.found
|
nodes.note.textContent = data.found
|
||||||
? "Lectura en vivo recibida. La pagina se actualiza cada 30 segundos."
|
? "Lectura en vivo recibida. El feed de bajas se actualiza en tiempo casi real."
|
||||||
: "Todavia no hay snapshot live disponible para este servidor.";
|
: "Todavia no hay snapshot live disponible para este servidor.";
|
||||||
nodes.scoreboard.href = scoreboardUrl || "./index.html";
|
nodes.scoreboard.href = scoreboardUrl || "./index.html";
|
||||||
nodes.scoreboard.hidden = !scoreboardUrl;
|
nodes.scoreboard.hidden = !scoreboardUrl;
|
||||||
@@ -150,6 +174,7 @@ function initializeKillFeed(nodes) {
|
|||||||
nodes.feedList = document.getElementById("current-match-feed-list");
|
nodes.feedList = document.getElementById("current-match-feed-list");
|
||||||
return {
|
return {
|
||||||
byId: new Map(),
|
byId: new Map(),
|
||||||
|
latestEventId: "",
|
||||||
visibleSignature: "",
|
visibleSignature: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -178,6 +203,7 @@ function renderKillFeed(data, nodes, state) {
|
|||||||
const incoming = Array.isArray(data.items) ? data.items : [];
|
const incoming = Array.isArray(data.items) ? data.items : [];
|
||||||
if (data.scope === "no-current-match-events") {
|
if (data.scope === "no-current-match-events") {
|
||||||
state.byId.clear();
|
state.byId.clear();
|
||||||
|
state.latestEventId = "";
|
||||||
}
|
}
|
||||||
incoming.forEach((event) => {
|
incoming.forEach((event) => {
|
||||||
if (event?.event_id) {
|
if (event?.event_id) {
|
||||||
@@ -187,6 +213,8 @@ function renderKillFeed(data, nodes, state) {
|
|||||||
const events = [...state.byId.values()]
|
const events = [...state.byId.values()]
|
||||||
.sort(compareKillFeedEvents)
|
.sort(compareKillFeedEvents)
|
||||||
.slice(0, CURRENT_MATCH_KILL_FEED_LIMIT);
|
.slice(0, CURRENT_MATCH_KILL_FEED_LIMIT);
|
||||||
|
state.byId = new Map(events.map((event) => [event.event_id, event]));
|
||||||
|
state.latestEventId = events[0]?.event_id || state.latestEventId;
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
nodes.feedList.innerHTML = "";
|
nodes.feedList.innerHTML = "";
|
||||||
state.visibleSignature = "";
|
state.visibleSignature = "";
|
||||||
@@ -227,7 +255,7 @@ function renderKillFeedRow(event) {
|
|||||||
title="${escapeHtml(weapon.label)}"
|
title="${escapeHtml(weapon.label)}"
|
||||||
aria-label="${escapeHtml(weapon.label)}"
|
aria-label="${escapeHtml(weapon.label)}"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">${escapeHtml(weapon.glyph)}</span>
|
${renderKillFeedWeaponIcon(weapon)}
|
||||||
<em>${escapeHtml(weapon.label)}</em>
|
<em>${escapeHtml(weapon.label)}</em>
|
||||||
</span>
|
</span>
|
||||||
<span class="current-match-killfeed__player current-match-killfeed__player--victim">
|
<span class="current-match-killfeed__player current-match-killfeed__player--victim">
|
||||||
@@ -242,10 +270,29 @@ function resolveKillFeedWeapon(value) {
|
|||||||
const key = normalizeLookupText(value);
|
const key = normalizeLookupText(value);
|
||||||
return CURRENT_MATCH_WEAPONS[key] || {
|
return CURRENT_MATCH_WEAPONS[key] || {
|
||||||
label: String(value || CURRENT_MATCH_WEAPONS.unknown.label),
|
label: String(value || CURRENT_MATCH_WEAPONS.unknown.label),
|
||||||
glyph: CURRENT_MATCH_WEAPONS.unknown.glyph,
|
icon: CURRENT_MATCH_WEAPONS.unknown.icon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderKillFeedWeaponIcon(weapon) {
|
||||||
|
if (!weapon.icon) {
|
||||||
|
return '<span class="current-match-killfeed__weapon-fallback" aria-hidden="true">?</span>';
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<img
|
||||||
|
class="current-match-killfeed__weapon-icon"
|
||||||
|
src="./assets/img/weapons/${escapeHtml(weapon.icon)}"
|
||||||
|
alt=""
|
||||||
|
width="88"
|
||||||
|
height="32"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onerror="this.hidden = true; this.nextElementSibling.hidden = false;"
|
||||||
|
/>
|
||||||
|
<span class="current-match-killfeed__weapon-fallback" aria-hidden="true" hidden>?</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderPlayerStats(data, nodes, state) {
|
function renderPlayerStats(data, nodes, state) {
|
||||||
const items = Array.isArray(data.items) ? sortPlayerStats(data.items) : [];
|
const items = Array.isArray(data.items) ? sortPlayerStats(data.items) : [];
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
@@ -262,6 +309,7 @@ function renderPlayerStats(data, nodes, state) {
|
|||||||
.map((item) =>
|
.map((item) =>
|
||||||
[
|
[
|
||||||
item.player_name,
|
item.player_name,
|
||||||
|
item.team,
|
||||||
item.kills,
|
item.kills,
|
||||||
item.deaths,
|
item.deaths,
|
||||||
item.teamkills,
|
item.teamkills,
|
||||||
@@ -312,10 +360,15 @@ function renderPlayerStatsTable(items) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderPlayerStatsRow(item) {
|
function renderPlayerStatsRow(item) {
|
||||||
|
const team = getPlayerTeamDisplay(item.team);
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr class="historical-player-row historical-player-row--${team.key}">
|
||||||
<td>${escapeHtml(item.player_name || "Jugador no disponible")}</td>
|
<td>${escapeHtml(item.player_name || "Jugador no disponible")}</td>
|
||||||
<td>${escapeHtml(item.team || "No disponible")}</td>
|
<td class="historical-player-team-cell">
|
||||||
|
<span class="historical-player-team-badge historical-player-team-badge--${team.key}">
|
||||||
|
${escapeHtml(team.label)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td>${escapeHtml(formatStatNumber(item.kills))}</td>
|
<td>${escapeHtml(formatStatNumber(item.kills))}</td>
|
||||||
<td>${escapeHtml(formatStatNumber(item.deaths))}</td>
|
<td>${escapeHtml(formatStatNumber(item.deaths))}</td>
|
||||||
<td>${escapeHtml(formatStatNumber(item.teamkills))}</td>
|
<td>${escapeHtml(formatStatNumber(item.teamkills))}</td>
|
||||||
@@ -325,6 +378,17 @@ function renderPlayerStatsRow(item) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPlayerTeamDisplay(value) {
|
||||||
|
const normalized = String(value || "").trim().toLowerCase();
|
||||||
|
if (normalized === "allies" || normalized === "allied" || normalized === "aliados") {
|
||||||
|
return { key: "allies", label: "Aliados" };
|
||||||
|
}
|
||||||
|
if (normalized === "axis" || normalized === "eje") {
|
||||||
|
return { key: "axis", label: "Eje" };
|
||||||
|
}
|
||||||
|
return { key: "unknown", label: "No disponible" };
|
||||||
|
}
|
||||||
|
|
||||||
function toStatNumber(value) {
|
function toStatNumber(value) {
|
||||||
return Number.isFinite(Number(value)) ? Number(value) : 0;
|
return Number.isFinite(Number(value)) ? Number(value) : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,65 +9,6 @@
|
|||||||
/>
|
/>
|
||||||
<title>Comunidad Hispana - HLL Vietnam</title>
|
<title>Comunidad Hispana - HLL Vietnam</title>
|
||||||
<link rel="stylesheet" href="./assets/css/styles.css" />
|
<link rel="stylesheet" href="./assets/css/styles.css" />
|
||||||
<style>
|
|
||||||
.server-card--stats {
|
|
||||||
min-height: 385px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-card__top--stats {
|
|
||||||
min-height: 255px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-card__status-column {
|
|
||||||
min-width: min(190px, 44%);
|
|
||||||
align-self: stretch;
|
|
||||||
align-content: start;
|
|
||||||
grid-template-rows: auto auto 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-card__actions {
|
|
||||||
align-self: end;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-card__actions .server-action-link:first-child {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-card__actions .server-action-link:nth-child(2) {
|
|
||||||
font-size: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-card__actions .server-action-link:nth-child(2)::before {
|
|
||||||
content: "Historico";
|
|
||||||
font-size: 0.76rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-card__quickfacts {
|
|
||||||
max-width: min(280px, 48%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
|
||||||
.server-card--stats,
|
|
||||||
.server-card__top--stats {
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-card__status-column {
|
|
||||||
min-width: 150px;
|
|
||||||
align-self: start;
|
|
||||||
grid-template-rows: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-card__actions {
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-card__quickfacts {
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
data-backend-base-url="http://127.0.0.1:8000"
|
data-backend-base-url="http://127.0.0.1:8000"
|
||||||
@@ -181,4 +122,4 @@
|
|||||||
|
|
||||||
<script src="./assets/js/main.js"></script>
|
<script src="./assets/js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user