diff --git a/ai/tasks/done/TASK-023-server-panel-ux-fixes-and-hero-cleanup.md b/ai/tasks/done/TASK-023-server-panel-ux-fixes-and-hero-cleanup.md new file mode 100644 index 0000000..a246d4a --- /dev/null +++ b/ai/tasks/done/TASK-023-server-panel-ux-fixes-and-hero-cleanup.md @@ -0,0 +1,101 @@ +# TASK-023-server-panel-ux-fixes-and-hero-cleanup + +## Goal +Corregir la UX del bloque de servidores y limpiar elementos visuales del hero de la landing, eliminando mensajes innecesarios, arreglando el CTA roto de conexion y dejando visible una referencia honesta de actualizacion del snapshot. + +## Context +La landing ya muestra datos actuales de los 2 servidores reales de la comunidad, pero todavia hay varios problemas de UX y acabado: +- aparece un texto largo e innecesario bajo el titulo del bloque de servidores indicando que el backend no pudo refrescar y que muestra el ultimo snapshot valido +- el boton `Conectar` no funciona correctamente y muestra errores de conexion como los observados en Steam +- el badge superior derecho del bloque de servidores debe pasar a mostrar una referencia limpia tipo `Actualizado ...` con el momento real correspondiente +- en el hero deben eliminarse los pills `BACKEND OPERATIVO` y `COMUNIDAD TACTICA HISPANA` + +La tarea debe dejar la UI mas limpia, mas honesta y sin CTAs rotos. + +## Steps +1. Revisar la implementacion actual del bloque de servidores en frontend y el payload real de `/api/servers`. +2. Eliminar del bloque de servidores el texto: + - `El backend no pudo refrescar ahora mismo y muestra el ultimo snapshot valido.` + - `Captura: ...` + o cualquier variante equivalente que recargue innecesariamente la UI. +3. Mantener la referencia temporal del snapshot unicamente en el badge superior derecho del bloque, sustituyendo el texto actual por un formato limpio tipo: + - `Actualizado 20/3/26, 19:37` + o equivalente coherente con el estilo visual actual. +4. Asegurar que el momento mostrado en ese badge viene de datos reales del snapshot/backend y no de texto ficticio. +5. Revisar la implementacion del boton `Conectar` de cada servidor. +6. Investigar por que el CTA actual produce el error observado y corregirlo si existe una forma fiable de conexion directa compatible con el flujo actual del proyecto. +7. Si la conexion directa no puede garantizarse de forma robusta con el comportamiento actual del juego/cliente, no dejar un boton roto: + - sustituirlo por una accion segura y util + - priorizar una UX que siempre funcione, por ejemplo `Copiar IP`, `Copiar direccion` o una variante equivalente basada en los datos reales del servidor + - si se conserva una accion de conexion, debe quedar realmente funcional +8. Mantener el comportamiento de los 2 servidores reales de la comunidad y no reintroducir servidores ficticios. +9. En el hero, eliminar visualmente: + - `BACKEND OPERATIVO` + - `COMUNIDAD TACTICA HISPANA` +10. Mantener intactos: + - logo + - CTA principal de Discord + - trailer + - estructura general de la landing +11. Asegurar que los cambios funcionan tanto en la UI estatica como en la UI hidratada por JS. +12. Al completar la implementacion: + - dejar el repositorio consistente + - hacer commit + - hacer push al remoto si el entorno lo permite + +## Files to Read First +- AGENTS.md +- ai/repo-context.md +- ai/architecture-index.md +- docs/frontend-backend-contract.md +- frontend/index.html +- frontend/assets/js/main.js +- frontend/assets/css/styles.css +- backend/app/payloads.py +- backend/app/routes.py +- backend/README.md + +## Expected Files to Modify +- frontend/index.html +- frontend/assets/js/main.js +- frontend/assets/css/styles.css +- opcionalmente backend/app/payloads.py si hace falta exponer mejor datos utiles para la UX del boton de servidor +- opcionalmente backend/README.md si cambia el comportamiento esperado del CTA de servidor + +## Constraints +- No volver a introducir mensajes largos y tecnicos en la UI principal del bloque de servidores. +- No dejar CTAs rotos o engañosos. +- No presentar datos no reales. +- No añadir librerias nuevas. +- No romper el polling ni la hidratacion actual del bloque. +- No romper el fallback actual. +- No hacer cambios destructivos. +- Mantener la solucion clara, visualmente limpia y coherente con la landing. + +## Validation +- Ya no aparece el texto largo bajo el titulo del bloque de servidores sobre fallo de refresh. +- El badge superior derecho muestra `Actualizado ...` con el momento real correspondiente al snapshot. +- El CTA de servidor deja de fallar: + - o conecta correctamente + - o se sustituye por una accion segura que si funciona +- Los 2 servidores reales de la comunidad siguen siendo los unicos mostrados. +- En el hero ya no aparecen `BACKEND OPERATIVO` ni `COMUNIDAD TACTICA HISPANA`. +- La landing mantiene coherencia visual y funcional. +- Los cambios quedan committeados y se hace push si el entorno lo permite. + +## Change Budget +- Preferir menos de 5 archivos modificados. +- Preferir menos de 220 lineas cambiadas. + +## Outcome +- `frontend/index.html` elimina los pills del hero, retira el texto secundario bajo el bloque de servidores y alinea el fallback estatico con los 2 servidores reales de Comunidad Hispana. +- `frontend/assets/js/main.js` deja la referencia temporal del snapshot solo en el badge superior derecho usando `last_snapshot_at` real del backend y sustituye la CTA rota `Conectar` por una accion segura de `Copiar IP`. +- `frontend/assets/css/styles.css` adapta las tarjetas al nuevo CTA, elimina estilos ya innecesarios del layout anterior y mantiene la presentacion limpia en desktop y movil. + +## Validation Result +- Validado con `node --check frontend/assets/js/main.js`. +- Verificado con busqueda en `frontend/` que ya no queda el texto largo del snapshot fallido ni `steam://connect`. +- Revisado con `git diff --name-only`: los cambios de esta task quedan limitados a `frontend/index.html`, `frontend/assets/js/main.js`, `frontend/assets/css/styles.css` y este archivo de task; permanecen sin tocar otros cambios locales ajenos. + +## Decision Notes +- La conexion directa por `steam://connect/:` se retira de la UI porque no podia garantizarse como CTA robusta en el flujo actual; se prioriza una accion segura y util basada en los datos reales del backend. diff --git a/frontend/assets/css/styles.css b/frontend/assets/css/styles.css index 9924e07..993a5e2 100644 --- a/frontend/assets/css/styles.css +++ b/frontend/assets/css/styles.css @@ -572,27 +572,6 @@ h2 { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); } -.server-card__load { - width: 100%; - height: 9px; - margin-bottom: 18px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.05); - overflow: hidden; - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.32); -} - -.server-card__load span { - display: block; - height: 100%; - border-radius: inherit; - background: - linear-gradient(90deg, rgba(210, 182, 118, 0.86), rgba(142, 160, 98, 0.96)); - box-shadow: - 0 0 18px rgba(142, 160, 98, 0.22), - inset 0 1px 0 rgba(255, 255, 255, 0.18); -} - .server-card__actions { margin: 0; width: 100%; @@ -600,7 +579,7 @@ h2 { justify-content: flex-end; } -.server-connect-button { +.server-copy-button { display: inline-flex; align-items: center; justify-content: center; @@ -617,19 +596,26 @@ h2 { font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; + cursor: pointer; transition: transform 160ms ease, border-color 160ms ease, - background 160ms ease; + background 160ms ease, + opacity 160ms ease; } -.server-connect-button:hover, -.server-connect-button:focus-visible { +.server-copy-button:hover, +.server-copy-button:focus-visible { transform: translateY(-1px); border-color: rgba(210, 182, 118, 0.52); background: linear-gradient(180deg, rgba(210, 182, 118, 0.2), rgba(142, 160, 98, 0.26)); } +.server-copy-button:disabled { + opacity: 0.78; + cursor: default; +} + .server-state { display: inline-flex; align-items: center; @@ -657,38 +643,6 @@ h2 { color: var(--accent-warm); } -.server-card__stats { - margin: 0; - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; -} - -.server-stat { - padding: 14px 14px 15px; - border: 1px solid rgba(159, 168, 141, 0.14); - border-radius: 16px; - background: linear-gradient(180deg, rgba(12, 15, 11, 0.38), rgba(8, 10, 7, 0.18)); -} - -.server-stat--players dd { - color: var(--accent-strong); -} - -.server-card__stats dt { - margin: 0 0 6px; - color: var(--muted); - font-size: 0.75rem; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.server-card__stats dd { - margin: 0; - font-size: 0.95rem; - line-height: 1.4; -} - .server-card__quickfacts { margin-bottom: 0; display: grid; @@ -836,10 +790,6 @@ h2 { justify-content: start; } - .server-card__stats { - grid-template-columns: 1fr; - } - .server-card__quickfacts { grid-template-columns: 1fr; } diff --git a/frontend/assets/js/main.js b/frontend/assets/js/main.js index 5981970..547f7c9 100644 --- a/frontend/assets/js/main.js +++ b/frontend/assets/js/main.js @@ -13,13 +13,12 @@ document.addEventListener("DOMContentLoaded", () => { const trailerFrame = document.getElementById("trailer-frame"); const trailerTitle = document.getElementById("trailer-title"); const serversTitle = document.getElementById("servers-title"); - const serversNote = document.getElementById("servers-note"); const serversList = document.getElementById("servers-list"); const serversBadge = document.getElementById("servers-badge"); updateBackendStatus(statusNode, "Backend comprobando", "status-chip--idle"); - - setServersDataState(serversBadge, { kind: "fallback" }); + setServersDataState(serversBadge, { timestampLabel: "" }); + bindCopyAddressActions(); let serverRefreshInFlight = false; const refreshServers = async () => { @@ -32,7 +31,6 @@ document.addEventListener("DOMContentLoaded", () => { await hydrateServers( backendBaseUrl, serversTitle, - serversNote, serversList, serversBadge, ); @@ -72,7 +70,11 @@ async function fetchHealth(backendBaseUrl, statusNode) { throw new Error("Unexpected health payload"); } catch (error) { console.warn("Backend health check unavailable", error); - updateBackendStatus(statusNode, "Modo estatico activo", "status-chip--fallback"); + updateBackendStatus( + statusNode, + "Modo estatico activo", + "status-chip--fallback", + ); } } @@ -104,11 +106,10 @@ async function hydrateTrailer(backendBaseUrl, trailerFrame, trailerTitle) { async function hydrateServers( backendBaseUrl, serversTitle, - serversNote, serversList, serversBadge, ) { - if (!serversTitle || !serversNote || !serversList || !serversBadge) { + if (!serversTitle || !serversList || !serversBadge) { return; } @@ -121,11 +122,7 @@ async function hydrateServers( serversTitle.textContent = serversData.title || "Estado actual de servidores"; - setServersDataState( - serversBadge, - deriveSnapshotState(serversData), - ); - serversNote.textContent = buildServersNote(serversData); + setServersDataState(serversBadge, deriveSnapshotState(serversData)); if (serversData.items.length === 0) { serversList.innerHTML = @@ -157,73 +154,12 @@ function setServersDataState(badgeNode, state) { return; } - if (state.kind === "live") { - badgeNode.textContent = state.timestampLabel - ? `Actualizado ${state.timestampLabel}` - : "Datos en vivo"; - badgeNode.classList.remove("status-chip--fallback"); - badgeNode.classList.add("status-chip--ok"); - return; - } - - if (state.kind === "historical") { - badgeNode.textContent = state.timestampLabel - ? `Snapshot ${state.timestampLabel}` - : "Actividad reciente"; - badgeNode.classList.remove("status-chip--fallback"); - badgeNode.classList.add("status-chip--ok"); - return; - } - - badgeNode.textContent = "Respaldo controlado"; - badgeNode.classList.remove("status-chip--ok"); - badgeNode.classList.add("status-chip--fallback"); -} - -function renderServerCard(server) { - const serverName = server.server_name || "Servidor sin nombre"; - const serverStatus = - server.status === "online" ? "Online" : server.status === "offline" ? "Offline" : "Estado pendiente"; - const stateClass = - server.status === "online" ? "server-state--online" : "server-state--offline"; - const currentMap = server.current_map || "Sin mapa disponible"; - const region = server.region || "Region pendiente"; - const players = Number.isFinite(server.players) ? server.players : 0; - const maxPlayers = Number.isFinite(server.max_players) ? server.max_players : 0; - const occupancy = getPopulationPercent(players, maxPlayers); - const sourceLabel = isRealA2SSnapshot(server) ? "Snapshot activo" : "Referencia actual"; - - return ` -
-
-
-

${escapeHtml(sourceLabel)}

-

${escapeHtml(serverName)}

-
-
- ${escapeHtml(serverStatus)} -

${players} / ${maxPlayers}

-
-
- -
-
-
Jugadores
-
${players} / ${maxPlayers}
-
-
-
Mapa
-
${escapeHtml(currentMap)}
-
-
-
Region
-
${escapeHtml(region)}
-
-
-
- `; + const hasTimestamp = typeof state.timestampLabel === "string" && state.timestampLabel; + badgeNode.textContent = hasTimestamp + ? `Actualizado ${state.timestampLabel}` + : "Actualizado no disponible"; + badgeNode.classList.toggle("status-chip--ok", Boolean(hasTimestamp)); + badgeNode.classList.toggle("status-chip--fallback", !hasTimestamp); } function renderServerStatsCard(server) { @@ -236,35 +172,28 @@ function renderServerStatsCard(server) { const region = server.region || "Region pendiente"; const players = Number.isFinite(server.players) ? server.players : 0; const maxPlayers = Number.isFinite(server.max_players) ? server.max_players : 0; - const occupancy = getPopulationPercent(players, maxPlayers); - const updatedAt = server.captured_at - ? formatTimestamp(server.captured_at) - : "Sin captura reciente"; - const updatedAgo = formatElapsedMinutes(server.history_summary?.minutes_since_last_capture); - const connectAction = renderConnectAction(server); + const address = getServerAddress(server); + const actionMarkup = renderServerAction(server); const cardVariantClass = isRealSnapshot ? "server-card--real" : "server-card--reference"; const quickFacts = renderQuickFacts([ { label: "Mapa", value: currentMap }, { label: "Region", value: region }, - { label: "Ultima captura", value: updatedAgo || updatedAt }, + { label: "Direccion", value: address || "No disponible" }, ]); return `
-

${isRealSnapshot ? "Snapshot activo" : "Referencia actual"}

+

${isRealSnapshot ? "Servidor de comunidad" : "Referencia actual"}

${escapeHtml(serverName)}

${escapeHtml(statusLabel)}

${escapeHtml(`${players} / ${maxPlayers}`)}

- ${connectAction} + ${actionMarkup}
- ${quickFacts}
`; @@ -278,21 +207,22 @@ function renderServerSections(latestItems) { `; } -function renderConnectAction(server) { - if (!server || !isRealA2SSnapshot(server)) { +function renderServerAction(server) { + const address = getServerAddress(server); + if (!address) { return ""; } - const host = typeof server.host === "string" ? server.host.trim() : ""; - const gamePort = Number.isInteger(server.game_port) ? server.game_port : Number(server.game_port); - if (!host || !Number.isInteger(gamePort) || gamePort <= 0) { - return ""; - } - - const connectUrl = `steam://connect/${host}:${gamePort}`; return `
- Conectar +
`; } @@ -314,6 +244,68 @@ function renderQuickFacts(items) { `; } +function bindCopyAddressActions() { + document.addEventListener("click", async (event) => { + const button = event.target.closest("[data-copy-address]"); + if (!(button instanceof HTMLButtonElement)) { + return; + } + + const address = button.dataset.copyAddress || ""; + if (!address) { + return; + } + + const defaultLabel = button.dataset.defaultLabel || "Copiar IP"; + button.disabled = true; + + try { + await copyText(address); + button.textContent = "IP copiada"; + } catch (error) { + console.warn("Could not copy server address", error); + button.textContent = "Copia manual"; + } finally { + window.setTimeout(() => { + button.textContent = defaultLabel; + button.disabled = false; + }, 1800); + } + }); +} + +async function copyText(value) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", ""); + textarea.style.position = "absolute"; + textarea.style.left = "-9999px"; + document.body.append(textarea); + textarea.select(); + + const copied = document.execCommand("copy"); + textarea.remove(); + + if (!copied) { + throw new Error("Clipboard copy command failed"); + } +} + +function getServerAddress(server) { + const host = typeof server?.host === "string" ? server.host.trim() : ""; + const gamePort = Number(server?.game_port); + if (!host || !Number.isInteger(gamePort) || gamePort <= 0) { + return ""; + } + + return `${host}:${gamePort}`; +} + function selectPrimaryServerItems(items) { if (!Array.isArray(items)) { return []; @@ -328,40 +320,11 @@ function isRealA2SSnapshot(item) { } function deriveSnapshotState(serversData) { - const timestampLabel = serversData?.last_snapshot_at - ? formatTimestamp(serversData.last_snapshot_at) - : ""; - - if (!serversData) { - return { kind: "fallback", timestampLabel }; - } - - if (serversData.freshness === "fresh" && serversData.source === "real-time-a2s-refresh") { - return { kind: "live", timestampLabel }; - } - - if (Array.isArray(serversData.items) && serversData.items.length > 0) { - return { kind: "historical", timestampLabel }; - } - - return { kind: "fallback", timestampLabel }; -} - -function buildServersNote(serversData) { - const lastSnapshotLabel = serversData.last_snapshot_at - ? formatTimestamp(serversData.last_snapshot_at) - : "sin timestamp disponible"; - const snapshotAgeLabel = formatSnapshotAge(serversData.snapshot_age_seconds); - - if (serversData.freshness === "fresh") { - return `Estado real consultado desde backend. Ultimo snapshot: ${lastSnapshotLabel}${snapshotAgeLabel ? `, ${snapshotAgeLabel}.` : "."}`; - } - - if (Array.isArray(serversData.items) && serversData.items.length > 0) { - return `El backend no pudo refrescar ahora mismo y muestra el ultimo snapshot valido. Captura: ${lastSnapshotLabel}${snapshotAgeLabel ? `, ${snapshotAgeLabel}.` : "."}`; - } - - return "El backend no pudo obtener un snapshot valido de los 2 servidores en este momento."; + return { + timestampLabel: serversData?.last_snapshot_at + ? formatTimestamp(serversData.last_snapshot_at) + : "", + }; } function formatServerStatus(status) { @@ -388,54 +351,6 @@ function formatTimestamp(timestamp) { }).format(value); } -function formatElapsedMinutes(minutes) { - if (!Number.isFinite(minutes)) { - return ""; - } - - if (minutes < 1) { - return "hace menos de 1 min"; - } - - if (minutes < 60) { - return `hace ${minutes} min`; - } - - const hours = Math.floor(minutes / 60); - if (hours < 24) { - return `hace ${hours} h`; - } - - const days = Math.floor(hours / 24); - return `hace ${days} d`; -} - -function formatSnapshotAge(snapshotAgeSeconds) { - if (!Number.isFinite(snapshotAgeSeconds)) { - return ""; - } - - if (snapshotAgeSeconds < 60) { - return `hace ${snapshotAgeSeconds} s`; - } - - const wholeMinutes = Math.floor(snapshotAgeSeconds / 60); - if (wholeMinutes < 60) { - return `hace ${wholeMinutes} min`; - } - - const wholeHours = Math.floor(wholeMinutes / 60); - return `hace ${wholeHours} h`; -} - -function getPopulationPercent(players, maxPlayers) { - if (!Number.isFinite(players) || !Number.isFinite(maxPlayers) || maxPlayers <= 0) { - return 0; - } - - return Math.max(0, Math.min(100, Math.round((players / maxPlayers) * 100))); -} - function getServerPollIntervalMs(rawValue) { const parsedValue = Number(rawValue); if (!Number.isFinite(parsedValue) || parsedValue <= 0) { diff --git a/frontend/index.html b/frontend/index.html index 23d4ec3..ed8f119 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -30,7 +30,6 @@ />
-

Comunidad táctica hispana

Comunidad Hispana HLL Vietnam @@ -40,13 +39,6 @@ Discord oficial de la comunidad.

-

- Backend no verificado -

Estado actual de servidores

- Referencia actual + Actualizado no disponible

-

- Estado actual y acceso directo a los servidores disponibles. -

-
-
+
+
-

Referencia actual

-

HLL ESP Tactical Rotation

+

Servidor de comunidad

+

Comunidad Hispana #01

Online

74 / 100

+
+ +
-
-
-
+
+
-

Referencia actual

-

HLL LATAM Night Offensive

+

Servidor de comunidad

+

Comunidad Hispana #02

- Online -

51 / 100

+ Offline +

0 / 100

+
+ +
-