feat: add current match page and live kill feed

This commit is contained in:
devRaGonSa
2026-05-21 15:18:03 +02:00
parent 4544cb3c0a
commit da3f1643a2
12 changed files with 703 additions and 50 deletions

View File

@@ -671,6 +671,8 @@ h2 {
margin: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
justify-content: flex-end;
}

View File

@@ -1,9 +1,16 @@
// Progressive enhancement for local frontend-backend checks.
const DEFAULT_SERVER_POLL_INTERVAL_MS = 300 * 1000;
const SERVER_HISTORY_URLS_FALLBACK = Object.freeze({
"comunidad-hispana-01": "https://scoreboard.comunidadhll.es/games",
"comunidad-hispana-02": "https://scoreboard.comunidadhll.es:5443/games",
"comunidad-hispana-03": "https://scoreboard.comunidadhll.es:3443/games",
const TRUSTED_SERVER_ACTIONS = Object.freeze({
"comunidad-hispana-01": Object.freeze({
publicScoreboardUrl: "https://scoreboard.comunidadhll.es",
historicalUrl: "./historico.html?server=comunidad-hispana-01",
currentMatchUrl: "./partida-actual.html?server=comunidad-hispana-01",
}),
"comunidad-hispana-02": Object.freeze({
publicScoreboardUrl: "https://scoreboard.comunidadhll.es:5443",
historicalUrl: "./historico.html?server=comunidad-hispana-02",
currentMatchUrl: "./partida-actual.html?server=comunidad-hispana-02",
}),
});
const COMMUNITY_CLANS = Object.freeze([
{
@@ -328,22 +335,8 @@ function normalizeServerRegion(value) {
}
function renderServerAction(server) {
const historyState = getServerHistoryState(server);
if (!historyState.available) {
return `
<div class="server-card__actions">
<span
class="server-action-link server-action-link--disabled"
aria-disabled="true"
>
Historico no disponible
</span>
</div>
`;
}
const historyUrl = historyState.url;
if (!historyUrl) {
const actions = getTrustedServerActions(server);
if (!actions) {
return "";
}
@@ -351,11 +344,17 @@ function renderServerAction(server) {
<div class="server-card__actions">
<a
class="server-action-link"
href="${escapeHtml(historyUrl)}"
href="${escapeHtml(actions.publicScoreboardUrl)}"
target="_blank"
rel="noreferrer"
>
Historico
Scoreboard publico
</a>
<a class="server-action-link" href="${escapeHtml(actions.historicalUrl)}">
Nuestro historico
</a>
<a class="server-action-link" href="${escapeHtml(actions.currentMatchUrl)}">
Partida actual
</a>
</div>
`;
@@ -458,28 +457,12 @@ function renderQuickFacts(items) {
`;
}
function getServerHistoryUrl(server) {
if (typeof server?.community_history_url === "string" && server.community_history_url.trim()) {
return server.community_history_url.trim();
}
function getTrustedServerActions(server) {
const externalServerId =
typeof server?.external_server_id === "string"
? server.external_server_id.trim()
: "";
if (!externalServerId) {
return "";
}
return SERVER_HISTORY_URLS_FALLBACK[externalServerId] || "";
}
function getServerHistoryState(server) {
const historyUrl = getServerHistoryUrl(server);
return {
available: Boolean(historyUrl),
url: historyUrl,
};
return TRUSTED_SERVER_ACTIONS[externalServerId] || null;
}
function selectPrimaryServerItems(items) {

View File

@@ -0,0 +1,256 @@
const CURRENT_MATCH_POLL_INTERVAL_MS = 30 * 1000;
const CURRENT_MATCH_SERVERS = Object.freeze({
"comunidad-hispana-01": "Comunidad Hispana #01",
"comunidad-hispana-02": "Comunidad Hispana #02",
});
document.addEventListener("DOMContentLoaded", () => {
const params = new URLSearchParams(window.location.search);
const serverSlug = params.get("server") || "";
const nodes = {
title: document.getElementById("current-match-title"),
summary: document.getElementById("current-match-summary"),
history: document.getElementById("current-match-history"),
scoreboard: document.getElementById("current-match-scoreboard"),
note: document.getElementById("current-match-note"),
state: document.getElementById("current-match-state"),
grid: document.getElementById("current-match-grid"),
feedTitle: document.getElementById("current-match-feed-title"),
};
const backendBaseUrl =
document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
if (!CURRENT_MATCH_SERVERS[serverSlug]) {
renderUnsupportedServer(nodes);
return;
}
nodes.history.href = `./historico.html?server=${encodeURIComponent(serverSlug)}`;
const killFeedState = initializeKillFeed(nodes);
let refreshInFlight = false;
const refresh = async () => {
if (refreshInFlight) {
return;
}
refreshInFlight = true;
try {
await Promise.allSettled([
loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }),
loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }),
]);
} finally {
refreshInFlight = false;
}
};
void refresh();
window.setInterval(() => {
void refresh();
}, CURRENT_MATCH_POLL_INTERVAL_MS);
});
async function loadCurrentMatch({ backendBaseUrl, serverSlug, nodes }) {
try {
const payload = await fetchJson(
`${backendBaseUrl}/api/current-match?server=${encodeURIComponent(serverSlug)}`,
);
renderCurrentMatch(payload?.data || {}, nodes);
} catch (error) {
nodes.note.textContent = "Se conserva el ultimo estado visible si estaba disponible.";
setState(nodes.state, "No se pudo actualizar la partida actual.", true);
}
}
async function loadKillFeed({ backendBaseUrl, serverSlug, nodes, killFeedState }) {
try {
const payload = await fetchJson(
`${backendBaseUrl}/api/current-match/kills?server=${encodeURIComponent(serverSlug)}&limit=30`,
);
renderKillFeed(payload?.data || {}, nodes, killFeedState);
} catch (error) {
setState(nodes.feedState, "No se pudo actualizar el feed de combate.", true);
}
}
function renderCurrentMatch(data, nodes) {
const serverName = data.server_name || data.server_slug || "Servidor no disponible";
const mapName = data.map || "Mapa no disponible";
nodes.title.textContent = serverName;
nodes.summary.textContent = mapName;
nodes.note.textContent = data.found
? "Snapshot en vivo recibido. La pagina se actualiza cada 30 segundos."
: "Todavia no hay snapshot live disponible para este servidor.";
nodes.scoreboard.href = data.public_scoreboard_url;
nodes.scoreboard.hidden = !data.public_scoreboard_url;
nodes.grid.innerHTML = [
renderStat("Estado", formatStatus(data.status)),
renderStat("Mapa", mapName),
renderStat("Modo", data.game_mode || "No disponible"),
renderStat("Inicio", formatTimestamp(data.started_at)),
renderStat("Jugadores", formatPlayers(data.players, data.max_players)),
renderStat("Marcador aliado", formatScore(data.allied_score)),
renderStat("Marcador eje", formatScore(data.axis_score)),
renderStat("Actualizado", formatTimestamp(data.captured_at || data.updated_at)),
].join("");
nodes.state.hidden = true;
nodes.grid.hidden = false;
}
function renderUnsupportedServer(nodes) {
nodes.title.textContent = "Servidor no soportado";
nodes.summary.textContent =
"Abre esta vista desde una tarjeta activa de Comunidad Hispana.";
nodes.note.textContent = "";
nodes.scoreboard.hidden = true;
nodes.grid.hidden = true;
setState(nodes.state, "No se puede consultar la partida solicitada.", true);
}
function initializeKillFeed(nodes) {
const feedShell = nodes.feedTitle?.closest(".panel__shell");
if (feedShell) {
feedShell.insertAdjacentHTML(
"beforeend",
`
<p class="historical-state" id="current-match-feed-state" aria-live="polite">
Cargando feed de combate...
</p>
<div class="historical-match-list" id="current-match-feed-list"></div>
`,
);
}
nodes.feedState = document.getElementById("current-match-feed-state");
nodes.feedList = document.getElementById("current-match-feed-list");
return {
byId: new Map(),
};
}
function renderKillFeed(data, nodes, state) {
const incoming = Array.isArray(data.items) ? data.items : [];
incoming.forEach((event) => {
if (event?.event_id) {
state.byId.set(event.event_id, event);
}
});
const events = [...state.byId.values()].sort(compareKillFeedEvents).slice(0, 30);
if (events.length === 0) {
nodes.feedList.innerHTML = "";
setState(nodes.feedState, "Todavia no se han detectado bajas en esta partida.");
return;
}
nodes.feedList.innerHTML = events.map(renderKillFeedRow).join("");
nodes.feedState.textContent =
data.scope === "open-admin-log-match-window"
? "Bajas del tramo abierto detectado por AdminLog."
: "Bajas recientes de AdminLog con cobertura parcial.";
nodes.feedState.classList.remove("historical-state--error");
}
function compareKillFeedEvents(left, right) {
const rightTime = Number(right.server_time);
const leftTime = Number(left.server_time);
if (Number.isFinite(rightTime) && Number.isFinite(leftTime) && rightTime !== leftTime) {
return rightTime - leftTime;
}
return String(right.event_timestamp || "").localeCompare(String(left.event_timestamp || ""));
}
function renderKillFeedRow(event) {
const teamkillBadge = event.is_teamkill
? '<span class="status-chip status-chip--fallback">TK</span>'
: "";
const eventTime = formatEventTime(event);
return `
<article class="historical-match-card">
<div class="historical-match-card__top">
<div class="historical-match-card__title">
<p>${escapeHtml(eventTime)}</p>
<strong>${escapeHtml(event.killer_name || "Jugador no disponible")}</strong>
</div>
${teamkillBadge}
</div>
<p>
${escapeHtml(event.weapon || "Arma no disponible")}
-> ${escapeHtml(event.victim_name || "Objetivo no disponible")}
</p>
</article>
`;
}
function formatEventTime(event) {
const timestamp = formatTimestamp(event.event_timestamp);
if (timestamp !== "No disponible") {
return timestamp;
}
return Number.isFinite(Number(event.server_time))
? `Tiempo servidor ${Number(event.server_time)}`
: "Tiempo no disponible";
}
function renderStat(label, value) {
return `
<article class="historical-stat-card">
<p>${escapeHtml(label)}</p>
<strong>${escapeHtml(value)}</strong>
</article>
`;
}
function formatStatus(value) {
if (value === "online") {
return "Online";
}
if (value === "offline") {
return "Offline";
}
return "No disponible";
}
function formatPlayers(players, maxPlayers) {
if (!Number.isFinite(Number(players)) || !Number.isFinite(Number(maxPlayers))) {
return "No disponible";
}
return `${Number(players)} / ${Number(maxPlayers)}`;
}
function formatScore(value) {
return Number.isFinite(Number(value)) ? String(Number(value)) : "No disponible";
}
function formatTimestamp(value) {
if (!value) {
return "No disponible";
}
const timestamp = new Date(value);
if (Number.isNaN(timestamp.getTime())) {
return "No disponible";
}
return new Intl.DateTimeFormat("es-ES", {
dateStyle: "short",
timeStyle: "short",
}).format(timestamp);
}
function setState(node, message, isError = false) {
node.textContent = message;
node.hidden = false;
node.classList.toggle("historical-state--error", isError);
}
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`);
}
return response.json();
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}

View File

@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Vista interna de la partida actual de Comunidad Hispana en HLL Vietnam."
/>
<title>Partida actual - HLL Vietnam</title>
<link rel="stylesheet" href="./assets/css/styles.css" />
<link rel="stylesheet" href="./assets/css/historico.css" />
</head>
<body data-backend-base-url="http://127.0.0.1:8000">
<div class="page-shell historical-shell">
<header class="hero historical-hero">
<div class="hero__overlay"></div>
<div class="hero__content historical-hero__content">
<div class="historical-hero__topline">
<a class="secondary-button secondary-button--ghost" href="./index.html">
VOLVER INICIO
</a>
<p class="eyebrow">Partida en vivo</p>
</div>
<div class="historical-hero__layout">
<div class="logo-frame historical-logo-frame">
<img
src="./assets/img/logo.png"
alt="Logo oficial de la comunidad HLL Vietnam"
class="logo-frame__image"
width="1024"
height="1044"
decoding="async"
/>
</div>
<div class="historical-hero__copy">
<div>
<h1 class="historical-hero__title" id="current-match-title">
Partida actual
</h1>
<p class="hero__text historical-hero__text" id="current-match-summary">
Cargando estado del servidor.
</p>
</div>
<div class="hero__actions">
<a class="secondary-button" id="current-match-history" href="./historico.html">
Abrir historico
</a>
<a
class="secondary-button"
id="current-match-scoreboard"
href="./index.html"
target="_blank"
rel="noreferrer"
hidden
>
Ver scoreboard publico
</a>
</div>
</div>
</div>
</div>
</header>
<main class="content historical-content">
<section class="panel historical-panel" aria-labelledby="current-match-panel-title">
<div class="panel__shell">
<div class="panel__header historical-panel__header">
<div>
<p class="eyebrow eyebrow--section">Estado en vivo</p>
<h2 id="current-match-panel-title">Marcador en curso</h2>
<p class="historical-panel__note" id="current-match-note">
Leyendo el ultimo snapshot disponible.
</p>
</div>
</div>
<p class="historical-state" id="current-match-state" aria-live="polite">
Cargando partida actual...
</p>
<div class="historical-summary-grid" id="current-match-grid" hidden></div>
</div>
</section>
<section class="panel historical-panel" aria-labelledby="current-match-feed-title">
<div class="panel__shell">
<div class="panel__header historical-panel__header">
<div>
<p class="eyebrow eyebrow--section">Combate</p>
<h2 id="current-match-feed-title">Feed de combate</h2>
<p class="historical-panel__note">
Los eventos de bajas en vivo apareceran aqui cuando se habiliten.
</p>
</div>
</div>
</div>
</section>
<section class="panel historical-panel" aria-labelledby="current-match-players-title">
<div class="panel__shell">
<div class="panel__header historical-panel__header">
<div>
<p class="eyebrow eyebrow--section">Jugadores</p>
<h2 id="current-match-players-title">Estadisticas en vivo</h2>
<p class="historical-panel__note">
Las estadisticas en vivo apareceran cuando haya datos suficientes.
</p>
</div>
</div>
</div>
</section>
</main>
</div>
<script src="./assets/js/partida-actual.js"></script>
</body>
</html>