feat: add internal match detail links
Add internal match detail links for recent matches without safe external URLs while preserving external links when available.
This commit is contained in:
252
frontend/assets/js/historico-partida.js
Normal file
252
frontend/assets/js/historico-partida.js
Normal file
@@ -0,0 +1,252 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const backendBaseUrl =
|
||||
document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const serverSlug = params.get("server") || "";
|
||||
const matchId = params.get("match") || "";
|
||||
const titleNode = document.getElementById("match-detail-title");
|
||||
const summaryNode = document.getElementById("match-detail-summary");
|
||||
const noteNode = document.getElementById("match-detail-note");
|
||||
const stateNode = document.getElementById("match-detail-state");
|
||||
const gridNode = document.getElementById("match-detail-grid");
|
||||
const actionsNode = document.getElementById("match-detail-actions");
|
||||
|
||||
if (!serverSlug || !matchId) {
|
||||
titleNode.textContent = "Partida no seleccionada";
|
||||
summaryNode.textContent = "Vuelve al historico y abre una partida registrada.";
|
||||
noteNode.textContent = "Faltan parametros internos para cargar este detalle.";
|
||||
setState(stateNode, "No hay una partida seleccionada.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
void loadMatchDetail({
|
||||
backendBaseUrl,
|
||||
serverSlug,
|
||||
matchId,
|
||||
titleNode,
|
||||
summaryNode,
|
||||
noteNode,
|
||||
stateNode,
|
||||
gridNode,
|
||||
actionsNode,
|
||||
});
|
||||
});
|
||||
|
||||
async function loadMatchDetail({
|
||||
backendBaseUrl,
|
||||
serverSlug,
|
||||
matchId,
|
||||
titleNode,
|
||||
summaryNode,
|
||||
noteNode,
|
||||
stateNode,
|
||||
gridNode,
|
||||
actionsNode,
|
||||
}) {
|
||||
try {
|
||||
const payload = await fetchJson(
|
||||
`${backendBaseUrl}/api/historical/matches/detail?server=${encodeURIComponent(
|
||||
serverSlug,
|
||||
)}&match=${encodeURIComponent(matchId)}`,
|
||||
);
|
||||
const data = payload?.data;
|
||||
const item = data?.item;
|
||||
if (!data?.found || !item) {
|
||||
titleNode.textContent = "Detalle no disponible";
|
||||
summaryNode.textContent =
|
||||
"La partida existe como enlace interno, pero todavia no hay detalle suficiente para mostrar.";
|
||||
noteNode.textContent =
|
||||
"El historico local puede tener solo una ventana RCON parcial o ningun registro ampliado.";
|
||||
setState(stateNode, "Detalle no disponible para esta partida.");
|
||||
return;
|
||||
}
|
||||
|
||||
renderMatchDetail(item, {
|
||||
titleNode,
|
||||
summaryNode,
|
||||
noteNode,
|
||||
stateNode,
|
||||
gridNode,
|
||||
actionsNode,
|
||||
});
|
||||
} catch (error) {
|
||||
titleNode.textContent = "Detalle no disponible";
|
||||
summaryNode.textContent = "No se pudo conectar con el backend local.";
|
||||
noteNode.textContent = "Comprueba que el backend este levantado y vuelve a intentarlo.";
|
||||
setState(stateNode, "Error al cargar el detalle de la partida.", true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMatchDetail(
|
||||
item,
|
||||
{ titleNode, summaryNode, noteNode, stateNode, gridNode, actionsNode },
|
||||
) {
|
||||
const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible";
|
||||
const serverName = item.server?.name || "Servidor no disponible";
|
||||
titleNode.textContent = mapName;
|
||||
summaryNode.textContent = `${serverName} | Partida ${item.match_id || "sin id"}`;
|
||||
noteNode.textContent = buildDetailNote(item);
|
||||
gridNode.innerHTML = [
|
||||
renderDetailCard("Servidor", serverName),
|
||||
renderDetailCard("Inicio", formatTimestamp(item.started_at)),
|
||||
renderDetailCard("Cierre", formatTimestamp(item.closed_at || item.ended_at)),
|
||||
renderDetailCard("Duracion", formatDuration(item.duration_seconds)),
|
||||
renderDetailCard("Jugadores", formatPlayerCount(item)),
|
||||
renderDetailCard("Marcador", formatScore(item.result)),
|
||||
renderDetailCard("Resultado", formatMatchResult(item.result)),
|
||||
renderDetailCard("Base de captura", formatCaptureBasis(item.capture_basis)),
|
||||
].join("");
|
||||
renderActions(item, actionsNode);
|
||||
stateNode.hidden = true;
|
||||
gridNode.hidden = false;
|
||||
}
|
||||
|
||||
function renderActions(item, actionsNode) {
|
||||
const matchUrl = normalizeExternalMatchUrl(item.match_url);
|
||||
if (!matchUrl) {
|
||||
actionsNode.innerHTML = "";
|
||||
actionsNode.hidden = true;
|
||||
return;
|
||||
}
|
||||
actionsNode.innerHTML = `
|
||||
<a
|
||||
class="historical-match-card__link"
|
||||
href="${escapeHtml(matchUrl)}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Abrir en scoreboard
|
||||
</a>
|
||||
`;
|
||||
actionsNode.hidden = false;
|
||||
}
|
||||
|
||||
function renderDetailCard(label, value) {
|
||||
return `
|
||||
<article class="historical-stat-card">
|
||||
<p>${escapeHtml(label)}</p>
|
||||
<strong>${escapeHtml(value)}</strong>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildDetailNote(item) {
|
||||
if (item.capture_basis === "rcon-competitive-window") {
|
||||
return "Detalle interno generado desde ventanas competitivas RCON. Algunos datos pueden estar limitados.";
|
||||
}
|
||||
return "Detalle servido desde el historico local disponible para esta partida.";
|
||||
}
|
||||
|
||||
function formatCaptureBasis(value) {
|
||||
if (value === "rcon-competitive-window") {
|
||||
return "Ventana competitiva RCON";
|
||||
}
|
||||
if (value === "public-scoreboard-match") {
|
||||
return "Scoreboard persistido";
|
||||
}
|
||||
return "Historico local";
|
||||
}
|
||||
|
||||
function formatPlayerCount(item) {
|
||||
if (Number.isFinite(Number(item.peak_players))) {
|
||||
return `${formatNumber(item.player_count)} media / ${formatNumber(item.peak_players)} pico`;
|
||||
}
|
||||
return formatNumber(item.player_count);
|
||||
}
|
||||
|
||||
function formatDuration(value) {
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
return "Duracion no disponible";
|
||||
}
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
return `${formatNumber(minutes)} min`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
return `${formatNumber(hours)} h ${formatNumber(remainingMinutes)} min`;
|
||||
}
|
||||
|
||||
function formatMatchResult(result) {
|
||||
const winner = result?.winner;
|
||||
if (winner === "allies") {
|
||||
return "Victoria Aliada";
|
||||
}
|
||||
if (winner === "axis") {
|
||||
return "Victoria Axis";
|
||||
}
|
||||
if (winner === "draw") {
|
||||
return "Empate";
|
||||
}
|
||||
return "Resultado parcial";
|
||||
}
|
||||
|
||||
function formatScore(result) {
|
||||
const alliedScore = Number.isFinite(result?.allied_score)
|
||||
? result.allied_score
|
||||
: "-";
|
||||
const axisScore = Number.isFinite(result?.axis_score) ? result.axis_score : "-";
|
||||
return `${alliedScore} - ${axisScore}`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
const parsedValue = Number(value);
|
||||
if (!Number.isFinite(parsedValue)) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat("es-ES").format(parsedValue);
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp) {
|
||||
if (!timestamp) {
|
||||
return "Fecha no disponible";
|
||||
}
|
||||
|
||||
const value = new Date(timestamp);
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
return "Fecha no disponible";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("es-ES", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function normalizeExternalMatchUrl(value) {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return ["http:", "https:"].includes(url.protocol) ? url.href : "";
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function setState(node, message, isError = false) {
|
||||
node.textContent = message;
|
||||
node.hidden = false;
|
||||
node.classList.toggle("is-error", isError);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
@@ -803,6 +803,7 @@ function hydrateMvpComparison(
|
||||
function renderRecentMatchCard(item) {
|
||||
const mapName = item.map?.pretty_name || item.map?.name || "Mapa no disponible";
|
||||
const matchUrl = normalizeExternalMatchUrl(item.match_url || item.source_url);
|
||||
const detailUrl = buildInternalMatchDetailUrl(item);
|
||||
const matchLink = matchUrl
|
||||
? `
|
||||
<a
|
||||
@@ -814,7 +815,16 @@ function renderRecentMatchCard(item) {
|
||||
Ver partida
|
||||
</a>
|
||||
`
|
||||
: "";
|
||||
: detailUrl
|
||||
? `
|
||||
<a
|
||||
class="historical-match-card__link"
|
||||
href="${escapeHtml(detailUrl)}"
|
||||
>
|
||||
Ver detalles
|
||||
</a>
|
||||
`
|
||||
: "";
|
||||
return `
|
||||
<article class="historical-match-card">
|
||||
<div class="historical-match-card__top">
|
||||
@@ -861,6 +871,24 @@ function normalizeExternalMatchUrl(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildInternalMatchDetailUrl(item) {
|
||||
const serverSlug = item?.server?.slug;
|
||||
const matchId = item?.match_id;
|
||||
if (typeof serverSlug !== "string" || !serverSlug.trim()) {
|
||||
return "";
|
||||
}
|
||||
if (typeof matchId !== "string" && typeof matchId !== "number") {
|
||||
return "";
|
||||
}
|
||||
const normalizedMatchId = String(matchId).trim();
|
||||
if (!normalizedMatchId) {
|
||||
return "";
|
||||
}
|
||||
return `./historico-partida.html?server=${encodeURIComponent(
|
||||
serverSlug.trim(),
|
||||
)}&match=${encodeURIComponent(normalizedMatchId)}`;
|
||||
}
|
||||
|
||||
function renderSummaryLoading(summaryNode) {
|
||||
summaryNode.innerHTML = renderSummaryCard("Estado", "Cargando datos historicos");
|
||||
}
|
||||
|
||||
74
frontend/historico-partida.html
Normal file
74
frontend/historico-partida.html
Normal file
@@ -0,0 +1,74 @@
|
||||
<!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="Detalle interno de una partida historica registrada por HLL Vietnam."
|
||||
/>
|
||||
<title>Detalle de partida - 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="./historico.html">
|
||||
VOLVER HISTORICO
|
||||
</a>
|
||||
<p class="eyebrow">Detalle interno</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="match-detail-title">
|
||||
Partida registrada
|
||||
</h1>
|
||||
<p class="hero__text historical-hero__text" id="match-detail-summary">
|
||||
Cargando datos disponibles del historico local.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content historical-content">
|
||||
<section class="panel historical-panel" aria-labelledby="match-detail-panel-title">
|
||||
<div class="panel__shell">
|
||||
<div class="panel__header historical-panel__header">
|
||||
<div>
|
||||
<p class="eyebrow eyebrow--section">Partida historica</p>
|
||||
<h2 id="match-detail-panel-title">Datos disponibles</h2>
|
||||
<p class="historical-panel__note" id="match-detail-note">
|
||||
Leyendo el detalle interno de la partida.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="historical-state" id="match-detail-state" aria-live="polite">
|
||||
Cargando detalle...
|
||||
</p>
|
||||
<div class="historical-summary-grid" id="match-detail-grid" hidden></div>
|
||||
<div class="historical-match-card__actions" id="match-detail-actions" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="./assets/js/historico-partida.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user