Ajustes UI

This commit is contained in:
devRaGonSa
2026-03-20 18:54:31 +01:00
parent 87c1f4e8c3
commit c50ab64047
19 changed files with 1111 additions and 713 deletions

View File

@@ -0,0 +1,57 @@
# TASK-018-hero-layout-rebalance
## Goal
Reequilibrar la composición visual del hero de la landing de HLL Vietnam para darle más impacto, reducir sensación de vacío vertical y mejorar la relación entre logo, titular, subtítulo y CTA principal.
## Context
La landing ya tiene una base visual sólida, pero el hero actual presenta varios puntos de mejora: demasiado espacio vertical, un titular muy dominante partido en varias líneas, un logo con menos protagonismo del deseado y una composición general menos potente que el resto de la página. Esta task debe centrarse únicamente en recomponer el bloque principal para que se perciba como una cabecera premium, más compacta y más cinematográfica, sin cambiar el alcance funcional actual.
## Steps
1. Revisar la composición actual del hero en la landing.
2. Evaluar el equilibrio visual entre:
- logo
- eyebrow o etiqueta superior
- título principal
- subtítulo o texto descriptivo
- chip de estado backend si aplica
- CTA principal
3. Reducir la sensación de vacío vertical y mejorar el ritmo del bloque.
4. Ajustar el protagonismo del logo para que se sienta más integrado y relevante.
5. Ajustar el titular para que mantenga fuerza, pero no rompa la composición ni monopolice toda la jerarquía visual.
6. Reordenar o afinar espaciados, anchuras máximas y alineaciones para que el conjunto se perciba más compacto y más intencional.
7. Mantener el hero claro, centrado en comunidad, branding y CTA.
8. Validar que la composición siga funcionando bien en móvil y escritorio.
9. No alterar enlaces, rutas ni comportamiento funcional existente.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- frontend/index.html
- frontend/assets/css/styles.css
- frontend/assets/js/main.js
## Expected Files to Modify
- frontend/index.html
- frontend/assets/css/styles.css
## Constraints
- No cambiar la ruta del logo.
- No cambiar el enlace del Discord.
- No cambiar el enlace del tráiler.
- No añadir nuevas secciones.
- No introducir librerías nuevas.
- No romper el fallback actual.
- No hacer cambios destructivos.
- Mantener la landing simple y coherente con el tono HLL Vietnam.
## Validation
- El hero se siente más compacto y más fuerte visualmente.
- El logo tiene mejor presencia relativa.
- El titular sigue siendo potente pero está mejor equilibrado.
- El CTA sigue siendo claro y visible.
- La cabecera transmite más sensación de portada principal.
- La composición sigue siendo responsive y estable.
## Change Budget
- Preferir menos de 2 archivos modificados.
- Preferir menos de 170 líneas cambiadas.

View File

@@ -0,0 +1,56 @@
# TASK-019-atmospheric-depth-pass
## Goal
Aumentar la profundidad visual y la atmósfera general de la landing de HLL Vietnam mediante un refinamiento controlado de fondo, overlays, iluminación y separación de planos, sin recargar la interfaz ni romper su sobriedad.
## Context
La paleta actual funciona y la base visual está bien encaminada, pero el fondo y el marco general aún se perciben algo planos. Hace falta una pasada de atmósfera para reforzar el tono Vietnam/táctico, mejorar la profundidad percibida y dar más cohesión al conjunto, especialmente alrededor del hero y de las secciones principales.
## Steps
1. Revisar el tratamiento actual del fondo y los overlays.
2. Evaluar cómo se perciben:
- profundidad
- viñeteado
- textura ambiental
- degradados
- separación entre hero y secciones
3. Refinar el sistema de fondo para aportar más atmósfera sin saturar.
4. Aplicar mejoras controladas en:
- overlays
- sombras de entorno
- iluminación indirecta
- separación de bloques
- sensación cinematográfica
5. Mantener una estética oscura, militar y limpia.
6. Evitar efectos exagerados, brillos excesivos o ruido visual.
7. Validar que el resultado no perjudica legibilidad ni rendimiento aparente.
8. Mantener intacto el comportamiento funcional de la landing.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- frontend/index.html
- frontend/assets/css/styles.css
## Expected Files to Modify
- frontend/assets/css/styles.css
- opcionalmente `frontend/index.html` si hiciera falta una envolvente mínima o ajuste estructural pequeño para soportar mejor la profundidad visual
## Constraints
- No cambiar contenido funcional.
- No añadir imágenes nuevas ni dependencias nuevas salvo que sea estrictamente innecesario.
- No rediseñar completamente la página.
- No tocar backend.
- No hacer cambios destructivos.
- Mantener el tono sobrio y cinematográfico.
## Validation
- La página transmite mayor profundidad visual.
- El fondo se siente menos plano.
- Hero y secciones se perciben mejor separados y más cohesionados.
- La atmósfera Vietnam/táctica queda reforzada.
- La legibilidad y claridad general se mantienen o mejoran.
## Change Budget
- Preferir menos de 2 archivos modificados.
- Preferir menos de 160 líneas cambiadas.

View File

@@ -0,0 +1,58 @@
# TASK-020-component-surface-polish
## Goal
Pulir visualmente superficies y componentes de la landing de HLL Vietnam para mejorar calidad percibida, consistencia y acabado en tarjetas, chips, badges, contenedores y detalles de UI.
## Context
La landing ya tiene una estructura visual bastante competente, pero aún puede ganar acabado en microdetalles de interfaz. El panel de servidores, las tarjetas, los chips de estado y los contenedores principales necesitan una pasada de consistencia visual para que el conjunto se sienta más refinado y más uniforme.
## Steps
1. Revisar los componentes visuales actuales de la landing.
2. Evaluar consistencia en:
- radios
- bordes
- sombras
- chips
- badges de estado
- tarjetas
- barras de ocupación
- encabezados de panel
3. Refinar esos elementos para que compartan un lenguaje visual más sólido y coherente.
4. Mejorar calidad percibida sin añadir complejidad innecesaria.
5. Asegurar que el panel de servidores y los componentes de apoyo se lean mejor tanto en estado estático como hidratado por JS.
6. Mantener clara la jerarquía entre componentes primarios y secundarios.
7. Validar responsive y consistencia entre escritorio y móvil.
8. No tocar el contrato del backend ni el contenido funcional de los payloads.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- frontend/index.html
- frontend/assets/css/styles.css
- frontend/assets/js/main.js
- backend/app/payloads.py
## Expected Files to Modify
- frontend/index.html
- frontend/assets/css/styles.css
- frontend/assets/js/main.js
## Constraints
- No cambiar endpoints.
- No cambiar payloads backend.
- No añadir librerías nuevas.
- No romper el fallback estático.
- No rediseñar completamente la landing.
- No hacer cambios destructivos.
- Mantener el ajuste centrado en UI y acabado visual.
## Validation
- Las superficies y componentes se ven más coherentes y mejor acabados.
- Chips, badges y tarjetas tienen una presentación más limpia y consistente.
- El panel de servidores gana claridad y calidad visual.
- La landing mantiene su funcionamiento actual.
- La mejora se aprecia en desktop y mobile.
## Change Budget
- Preferir menos de 3 archivos modificados.
- Preferir menos de 190 líneas cambiadas.

View File

@@ -0,0 +1,60 @@
# TASK-043-unified-page-shell-and-section-width-system
## Goal
Unificar el sistema de anchuras de la pagina para que hero, trailer y panel de servidores compartan el mismo ancho util y se perciban como parte de una misma experiencia visual, eliminando la sensacion actual de secciones con tamanos distintos.
## Context
La UI actual ya funciona y muestra datos reales, pero las secciones principales no comparten una anchura consistente. Esto hace que la pagina se sienta fragmentada y reduce la sensacion de producto final. El objetivo de esta task es definir un unico sistema de shell/layout para las secciones principales y aplicarlo de forma consistente.
## Steps
1. Revisar la estructura actual de shells, contenedores y max-width del frontend.
2. Identificar diferencias de ancho entre:
- hero
- bloque del trailer
- bloque de servidores
3. Definir un unico ancho maestro para las secciones principales en desktop.
4. Hacer que hero, trailer y panel de servidores usen el mismo carril visual principal.
5. Ajustar margenes laterales, paddings y separacion vertical para evitar sensacion de fragmentacion.
6. Mantener el comportamiento responsive correcto en tablet y movil.
7. No redisenar el contenido, solo el sistema de layout y anchuras.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- frontend/index.html
- frontend/assets/css/styles.css
- frontend/assets/js/main.js
## Expected Files to Modify
- frontend/index.html
- frontend/assets/css/styles.css
## Constraints
- No tocar backend.
- No anadir librerias nuevas.
- No hacer cambios destructivos.
- Mantener la identidad visual actual.
- Mantener el trabajo centrado en shell/layout.
## Validation
- Hero, trailer y panel de servidores comparten el mismo ancho util en desktop.
- La pagina se percibe mas unificada.
- No quedan secciones visualmente demasiado estrechas respecto a otras.
- El responsive sigue funcionando.
## Change Budget
- Preferir menos de 3 archivos modificados.
- Preferir menos de 180 lineas cambiadas.
## Outcome
- `frontend/index.html` introduce un `panel__shell` comun en trailer y servidores para que ambas secciones compartan el mismo carril interior.
- `frontend/assets/css/styles.css` reemplaza los anchos independientes de hero, trailer y servidores por un sistema unificado con `--page-shell-width` y `--panel-content-width`.
- El ajuste se mantuvo centrado en layout, paddings y separacion vertical, sin redisenar el contenido.
## Validation Result
- Revisado en diff: la task queda limitada a `frontend/index.html`, `frontend/assets/css/styles.css` y el archivo de task, dentro del scope esperado.
- Revisado en codigo: hero, trailer y panel de servidores pasan a compartir un mismo ancho maestro en desktop y un mismo carril interior para el contenido principal.
- El responsive existente se conserva con los breakpoints moviles previos y con padding especifico para paneles en `max-width: 640px`.
## Decision Notes
- Se unifico el sistema de shell a nivel de seccion y de carril interior en lugar de tocar el contenido de cada bloque, para cumplir el objetivo visual sin abrir un rediseno mayor.

View File

@@ -0,0 +1,59 @@
# TASK-044-remove-test-signals-and-technical-summary-strips
## Goal
Eliminar de la interfaz principal los elementos, etiquetas y resumenes que den sensacion de entorno de pruebas o validacion tecnica, dejando una experiencia mas limpia y orientada a producto final.
## Context
La pagina ya esta entrando en una fase mas cercana a producto final. Sin embargo, siguen apareciendo elementos como "captura real reciente", resumenes tecnicos o textos que funcionan bien para desarrollo, pero no para una experiencia publica mas madura.
## Steps
1. Revisar el bloque actual de servidores y los elementos intermedios previos a la rejilla de tarjetas.
2. Eliminar o simplificar los elementos que indiquen:
- validacion interna
- estado de prueba
- resumenes tecnicos poco utiles para el visitante
3. Quitar la seccion visual equivalente a "captura real reciente".
4. Quitar la fila o bloque de resumen tipo "vista previa historica" si no aporta valor claro al usuario final.
5. Mantener solo el copy que ayuda a entender el bloque como producto real.
6. Asegurar que el panel de servidores quede mas limpio y mas directo.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- frontend/index.html
- frontend/assets/js/main.js
- frontend/assets/css/styles.css
## Expected Files to Modify
- frontend/index.html
- frontend/assets/js/main.js
- frontend/assets/css/styles.css
## Constraints
- No tocar backend.
- No romper el consumo de datos reales.
- No eliminar fallbacks internos, solo su exposicion innecesaria en la UI.
- No hacer cambios destructivos.
- Mantener el ajuste centrado en limpieza de producto.
## Validation
- Desaparecen indicios de "modo prueba" en la vista principal.
- El bloque de servidores se percibe mas limpio y mas orientado a usuario final.
- La pagina mantiene funcionalidad completa.
## Change Budget
- Preferir menos de 3 archivos modificados.
- Preferir menos de 140 lineas cambiadas.
## Outcome
- `frontend/index.html` elimina el bloque visual de fuente/estado y la franja de resumen historico previa a la rejilla.
- `frontend/assets/js/main.js` mantiene la logica interna de estados, pero reduce la exposicion publica a un badge limpio y copy orientado a producto.
- `frontend/assets/css/styles.css` retira los estilos de los strips tecnicos eliminados.
## Validation Result
- Revisado en codigo: los datos reales siguen hidratando el panel y la diferenciacion interna entre live, historical y fallback se conserva en JavaScript.
- Validado con `node --check frontend/assets/js/main.js`.
- Revisado en diff: la task queda limitada a `frontend/index.html`, `frontend/assets/js/main.js`, `frontend/assets/css/styles.css` y el archivo de task.
## Decision Notes
- La limpieza se resolvio ocultando por completo los bloques tecnicos y simplificando el lenguaje de estado, en lugar de introducir nuevas superficies UI intermedias.

View File

@@ -0,0 +1,69 @@
# TASK-045-simplify-real-server-cards-for-product-ui
## Goal
Simplificar las tarjetas de servidores reales para mostrar solo la informacion mas util para el visitante final, mejorando la claridad y reduciendo ruido visual y tecnico.
## Context
Las tarjetas actuales ya muestran datos reales A2S, pero siguen ensenando informacion que no aporta suficiente valor para la experiencia principal. La tarjeta debe centrarse en lo esencial: nombre, estado, jugadores, mapa, region, ultima captura y CTA de conexion. Cualquier metrica adicional debe ser secundaria o eliminarse si no mejora claramente la lectura.
## Steps
1. Revisar la estructura actual de las tarjetas reales.
2. Reducir la informacion visible principal a:
- nombre
- estado
- jugadores actuales
- mapa
- region
- ultima captura
- boton Conectar
3. Evaluar si promedio y pico deben quedar como datos secundarios discretos o desaparecer.
4. Eliminar de la tarjeta elementos como:
- tendencia reciente
- numero de capturas
- bloques densos poco utiles
5. Reorganizar la tarjeta para que se lea rapido y con claridad.
6. Mantener una composicion limpia y coherente con el diseno actual.
7. Asegurar buena legibilidad en desktop y movil.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- frontend/index.html
- frontend/assets/js/main.js
- frontend/assets/css/styles.css
- backend/app/payloads.py
## Expected Files to Modify
- frontend/index.html
- frontend/assets/js/main.js
- frontend/assets/css/styles.css
## Constraints
- No tocar backend salvo alineacion menor si fuera imprescindible.
- No romper la CTA Conectar.
- No anadir librerias nuevas.
- No hacer cambios destructivos.
- Mantener el ajuste centrado en UX de producto final.
## Validation
- Las tarjetas reales muestran solo la informacion mas util y legible.
- Desaparece ruido tecnico innecesario.
- La CTA Conectar sigue visible y clara.
- La UI gana sensacion de producto final.
## Change Budget
- Preferir menos de 3 archivos modificados.
- Preferir menos de 160 lineas cambiadas.
## Outcome
- `frontend/assets/js/main.js` simplifica el render de tarjetas reales a identidad, estado, poblacion, quick facts y CTA, eliminando resumen historico, tendencia y llamadas auxiliares ya innecesarias.
- `frontend/assets/css/styles.css` elimina estilos asociados a bloques tecnicos densos y deja una composicion mas directa para las quick facts.
- No fue necesario tocar backend ni cambiar la CTA `Conectar`.
## Validation Result
- Validado con `node --check frontend/assets/js/main.js`.
- Revisado en codigo: las tarjetas mantienen nombre, estado, jugadores, mapa, region, ultima captura y CTA, y desaparecen tendencia, capturas, promedio y pico.
- Revisado en diff: la task queda limitada a `frontend/assets/js/main.js`, `frontend/assets/css/styles.css` y el archivo de task.
## Decision Notes
- Se opto por eliminar por completo las metricas secundarias en lugar de rebajarlas visualmente, porque seguian cargando la tarjeta y no aportaban suficiente valor a la lectura principal.

View File

@@ -0,0 +1,44 @@
# TASK-046-near-full-width-master-shell
## Goal
Sustituir la columna central demasiado estrecha por un shell maestro mucho más ancho, de forma que la landing use casi todo el ancho útil de desktop y reduzca drásticamente el espacio vacío lateral.
## Context
La UI ya unificó las secciones bajo un mismo sistema de anchura, pero ese sistema sigue siendo demasiado estrecho. El resultado es una página encajada en el centro con mucho espacio muerto a izquierda y derecha. El objetivo de esta task es ampliar el carril principal de la landing hasta un ancho cercano al total útil de la ventana, manteniendo márgenes razonables.
## Steps
1. Revisar el shell principal actual y cualquier `max-width` asociado.
2. Sustituir el ancho maestro actual por uno claramente más amplio en desktop.
3. Usar una referencia tipo:
- `width: min(1600px, calc(100vw - 64px))`
o equivalente coherente con el diseño actual.
4. Mantener márgenes laterales razonables sin dejar la web pegada a los bordes.
5. Asegurar que el comportamiento responsive en tablet y móvil siga siendo correcto.
6. No rediseñar contenidos; solo ampliar el carril principal de la landing.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- frontend/index.html
- frontend/assets/css/styles.css
## Expected Files to Modify
- frontend/assets/css/styles.css
- frontend/index.html si fuera necesario alinear wrappers
## Constraints
- No tocar backend.
- No añadir librerías nuevas.
- No hacer cambios destructivos.
- Mantener la identidad visual actual.
- Mantener el trabajo centrado en el shell principal.
## Validation
- La página usa mucho mejor el ancho disponible en desktop.
- Disminuye claramente el espacio vacío lateral.
- Hero, tráiler y servidores siguen alineados en un mismo carril visual.
- Tablet y móvil no se rompen.
## Change Budget
- Preferir menos de 2 archivos modificados.
- Preferir menos de 120 líneas cambiadas.

View File

@@ -0,0 +1,44 @@
# TASK-047-remove-nested-width-constraints
## Goal
Eliminar o corregir restricciones internas de anchura que siguen estrechando hero, tráiler y panel de servidores aunque el shell principal se haya ensanchado.
## Context
En la UI actual hay indicios claros de que, además del shell principal, existen wrappers interiores o `max-width` secundarios que mantienen las secciones demasiado estrechas. Esta task debe identificar y eliminar esas restricciones internas para que cada sección ocupe realmente el ancho disponible del carril principal.
## Steps
1. Revisar la estructura HTML y CSS de hero, tráiler y panel de servidores.
2. Identificar wrappers internos con `max-width`, anchuras fijas o márgenes automáticos que estrechen el contenido.
3. Corregir esas restricciones para que:
- hero
- tráiler
- panel de servidores
usen `width: 100%` dentro del shell principal.
4. Mantener una composición limpia y centrada, sin volver a una columna estrecha.
5. Evitar que un wrapper interno rompa el ancho unificado buscado.
6. No rediseñar secciones; solo liberar el ancho real utilizable.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- frontend/index.html
- frontend/assets/css/styles.css
## Expected Files to Modify
- frontend/index.html
- frontend/assets/css/styles.css
## Constraints
- No tocar backend.
- No añadir librerías nuevas.
- No hacer cambios destructivos.
- Mantener la estructura visual existente salvo correcciones de anchura.
## Validation
- No quedan wrappers interiores que vuelvan a estrechar las secciones principales.
- Hero, tráiler y servidores ocupan realmente el ancho del shell maestro.
- La sensación de “columna central demasiado angosta” desaparece o se reduce claramente.
## Change Budget
- Preferir menos de 2 archivos modificados.
- Preferir menos de 120 líneas cambiadas.

View File

@@ -0,0 +1,43 @@
# TASK-048-wide-desktop-server-grid-pass
## Goal
Aprovechar el nuevo ancho de desktop para que el panel de servidores use una rejilla más amplia y cómoda, evitando que las tarjetas sigan viéndose pequeñas o demasiado encerradas.
## Context
Una vez ampliado el shell principal y eliminadas restricciones internas, el panel de servidores debe beneficiarse directamente de ese ancho. Si la rejilla sigue siendo demasiado conservadora, la página seguirá dando sensación de estrechez aunque el contenedor ya sea ancho.
## Steps
1. Revisar la rejilla actual del panel de servidores.
2. Ajustar el grid para que aproveche mejor desktop ancho.
3. Usar una estrategia robusta tipo:
- `repeat(auto-fit, minmax(360px, 1fr))`
o equivalente mejor adaptada al diseño actual.
4. Revisar gaps, paddings y anchuras mínimas para evitar tarjetas pequeñas o apretadas.
5. Mantener buen comportamiento en tablet y móvil.
6. No rediseñar la tarjeta; centrarse en cómo la rejilla usa el ancho disponible.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- frontend/assets/css/styles.css
- frontend/assets/js/main.js
- frontend/index.html
## Expected Files to Modify
- frontend/assets/css/styles.css
- frontend/index.html si hiciera falta alinear clases o wrappers
## Constraints
- No tocar backend.
- No añadir librerías nuevas.
- No hacer cambios destructivos.
- Mantener el trabajo centrado en grid y aprovechamiento del ancho.
## Validation
- El panel de servidores se ve más amplio y mejor repartido en desktop.
- Las tarjetas dejan de sentirse pequeñas o encajadas.
- La UI mantiene buena lectura en tablet y móvil.
## Change Budget
- Preferir menos de 2 archivos modificados.
- Preferir menos de 120 líneas cambiadas.

View File

@@ -0,0 +1,62 @@
# TASK-049-remove-nonessential-copy-and-technical-badges
## Goal
Eliminar de la landing los textos descriptivos y etiquetas tecnicas que no aportan valor al usuario final, dejando una interfaz mas limpia, directa y orientada a producto final.
## Context
La UI ya esta en una fase suficientemente madura como para prescindir de copy de presentacion y de labels internos o tecnicos. Actualmente siguen apareciendo textos de relleno o de tono demasiado explicativo, asi como etiquetas como "Snapshot real A2S", que no deberian estar visibles en la vista principal publica.
## Steps
1. Revisar el hero, bloque de trailer y bloque de servidores.
2. Eliminar o simplificar textos descriptivos no esenciales, incluyendo ejemplos como:
- descripciones explicativas del trailer
- copy redundante del bloque de servidores
- textos internos de validacion o sistema
3. Eliminar de las tarjetas reales etiquetas tecnicas como:
- "Snapshot real A2S"
4. Revisar tambien chips o badges secundarios que aporten poco valor al usuario final.
5. Mantener solo el texto minimo necesario para entender cada seccion.
6. Preservar claridad y estetica sin dejar la pagina vacia ni fria.
7. No tocar backend en esta task.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- frontend/index.html
- frontend/assets/js/main.js
- frontend/assets/css/styles.css
## Expected Files to Modify
- frontend/index.html
- frontend/assets/js/main.js
- frontend/assets/css/styles.css
## Constraints
- No tocar backend.
- No eliminar informacion util de servidor como mapa, jugadores, region, estado, ultima actualizacion y CTA.
- No anadir librerias nuevas.
- No hacer cambios destructivos.
- Mantener el ajuste centrado en copy/UI.
## Validation
- Desaparecen textos descriptivos innecesarios.
- Desaparecen etiquetas tecnicas como "Snapshot real A2S".
- La pagina se percibe mas limpia y mas finalista.
- La informacion util del servidor sigue intacta.
## Change Budget
- Preferir menos de 3 archivos modificados.
- Preferir menos de 120 lineas cambiadas.
## Outcome
- `frontend/index.html` elimina copy descriptivo del hero y del bloque de trailer, simplifica el encabezado de servidores y quita etiquetas secundarias en las tarjetas estaticas.
- `frontend/assets/js/main.js` sustituye textos tecnicos por textos de producto mas directos y elimina labels internos visibles en tarjetas y estados del bloque de servidores.
- `frontend/assets/css/styles.css` elimina el estilo del texto descriptivo del hero ya retirado del markup.
## Validation Result
- Validado con `node --check frontend/assets/js/main.js`.
- Verificado con `rg` que ya no quedan labels visibles como `Snapshot real A2S`, `Fallback estatico`, `Servidor de referencia` o equivalentes en `frontend/`.
- Revisado en diff: la task queda limitada a `frontend/index.html`, `frontend/assets/js/main.js`, `frontend/assets/css/styles.css` y este archivo de task. El cambio no toca backend.
## Decision Notes
- Se mantuvo una nota breve en el bloque de servidores para no dejar la seccion fria, pero se reescribio a una forma claramente orientada a usuario final y sin lenguaje interno del sistema.

View File

@@ -0,0 +1,67 @@
# TASK-050-live-snapshot-refresh-and-frontend-polling-alignment
## Goal
Alinear la captura periodica de snapshots y el consumo del frontend para que la web muestre datos mucho mas cercanos al estado real actual de los servidores, evitando que se queden estancados durante horas.
## Context
La pagina ya consume datos reales A2S, pero en la practica estaba mostrando snapshots demasiado antiguos, con mapas y poblacion desactualizados. Esto degrada la percepcion de fiabilidad del producto. El sistema necesitaba una politica mas util de refresco local y una lectura periodica razonable desde frontend.
## Steps
1. Revisar como se ejecuta actualmente el scheduler local de snapshots.
2. Revisar la frecuencia de captura actual o la ausencia de ejecucion continua.
3. Definir una frecuencia de refresco razonable para entorno local de desarrollo y demo, por ejemplo:
- captura backend cada 60 segundos
- refresco frontend cada 60-90 segundos
4. Ajustar el scheduler o su configuracion para facilitar un refresco frecuente y controlado.
5. Revisar el frontend para que vuelva a consultar el backend periodicamente sin necesidad de recargar la pagina.
6. Asegurar que el refresco no rompa la UI ni genere comportamiento agresivo.
7. Mantener el sistema simple y apropiado para la fase actual.
8. Documentar claramente como arrancar el backend y el refresco para ver datos vivos.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- backend/README.md
- backend/app/config.py
- backend/app/scheduler.py
- backend/app/collector.py
- frontend/assets/js/main.js
- frontend/index.html
## Expected Files to Modify
- backend/README.md
- backend/app/config.py
- backend/app/scheduler.py
- frontend/assets/js/main.js
- opcionalmente frontend/index.html si requiere una alineacion minima
## Constraints
- No introducir infraestructura pesada.
- No anadir librerias nuevas.
- No hacer cambios destructivos.
- No abrir nuevas fuentes de datos.
- Mantener la solucion simple, local y util para producto actual.
## Validation
- El sistema puede refrescar snapshots reales con una frecuencia razonable.
- El frontend actualiza la vista sin depender de recarga manual.
- Los mapas y jugadores cambian cuando cambian realmente en los servidores.
- La documentacion deja claro como usar este flujo en local.
## Change Budget
- Preferir menos de 5 archivos modificados.
- Preferir menos de 180 lineas cambiadas.
## Outcome
- `backend/app/config.py` baja el intervalo por defecto del scheduler local a `60` segundos para acercar la persistencia a una demo viva.
- `backend/app/scheduler.py` alinea la ayuda del comando con ese nuevo comportamiento orientado a desarrollo y demo.
- `frontend/index.html` expone `data-server-refresh-ms=\"60000\"` y `frontend/assets/js/main.js` reutiliza ese valor para relanzar `hydrateServers()` cada `60` segundos sin recargar la pagina y sin solapar peticiones.
- `backend/README.md` documenta el flujo local recomendado con backend, scheduler y polling del frontend.
## Validation Result
- Validado con `node --check frontend/assets/js/main.js`.
- Validado con `python -m py_compile backend/app/config.py backend/app/scheduler.py backend/app/collector.py`.
- Revisado en diff: la task queda limitada a `backend/README.md`, `backend/app/config.py`, `backend/app/scheduler.py`, `frontend/assets/js/main.js`, `frontend/index.html` y este archivo de task.
## Decision Notes
- Se mantuvo el polling solo sobre el bloque de servidores para no convertir toda la landing en una pagina con refresco agresivo. Salud del backend y trailer siguen siendo una hidratacion inicial simple.

View File

@@ -0,0 +1,66 @@
# TASK-051-real-server-card-minimal-product-pass
## Goal
Dejar las tarjetas de servidores reales en una forma minima y orientada a producto final, mostrando solo la informacion esencial para un visitante que quiere ver estado actual y conectarse.
## Context
Las tarjetas ya se simplificaron parcialmente, pero todavia quedaban restos de estructura y bloques secundarios que no aportaban suficiente valor. El objetivo era dejar una tarjeta muy clara y minima, centrada en estado actual del servidor.
## Steps
1. Revisar la composicion actual de las tarjetas reales.
2. Asegurar que el contenido visible principal quede limitado a:
- nombre
- estado
- jugadores
- mapa
- region
- ultima actualizacion
- boton Conectar
3. Evaluar si promedio y pico deben quedar ocultos o pasar a una capa secundaria muy discreta.
4. Eliminar cualquier bloque residual que complique la lectura rapida.
5. Mejorar la jerarquia para que el usuario vea en pocos segundos si el servidor le interesa.
6. Mantener una composicion limpia y coherente con el ancho actual de la pagina.
7. No tocar backend salvo una alineacion menor si fuera imprescindible.
## Files to Read First
- AGENTS.md
- ai/repo-context.md
- frontend/index.html
- frontend/assets/js/main.js
- frontend/assets/css/styles.css
- backend/app/payloads.py
## Expected Files to Modify
- frontend/index.html
- frontend/assets/js/main.js
- frontend/assets/css/styles.css
## Constraints
- No romper el boton Conectar.
- No eliminar la informacion util esencial.
- No anadir librerias nuevas.
- No hacer cambios destructivos.
- Mantener el ajuste centrado en UX de producto final.
## Validation
- Las tarjetas muestran solo informacion util y clara.
- La lectura es rapida y limpia.
- No quedan bloques residuales de informacion interna o excesiva.
- La UI se siente mas final y menos experimental.
## Change Budget
- Preferir menos de 3 archivos modificados.
- Preferir menos de 140 lineas cambiadas.
## Outcome
- `frontend/assets/js/main.js` deja las tarjetas dinamicas en su forma minima: identidad, estado, jugadores, mapa, region, ultima actualizacion y CTA cuando existe destino conectable.
- `frontend/index.html` alinea las tarjetas estaticas de fallback con esa misma estructura minima.
- `frontend/assets/css/styles.css` elimina estilos de bloques residuales ya innecesarios, como la barra de carga y los encabezados intermedios de seccion, para reforzar una lectura mas rapida.
## Validation Result
- Validado con `node --check frontend/assets/js/main.js`.
- Verificado con `rg` que ya no quedan restos de `server-card__load`, `server-panel-section`, ni texto asociado a metricas o bloques historicos secundarios dentro de `frontend/`.
- Revisado en diff: la task queda limitada a `frontend/index.html`, `frontend/assets/js/main.js`, `frontend/assets/css/styles.css` y este archivo de task.
## Decision Notes
- Se elimino la barra visual de ocupacion porque duplicaba la informacion ya expresada por `Jugadores` y cargaba la tarjeta sin mejorar la decision principal del visitante.

View File

@@ -1 +1 @@
39568 32960

View File

@@ -61,6 +61,11 @@ Variables opcionales:
- `HLL_BACKEND_ALLOWED_ORIGINS` - `HLL_BACKEND_ALLOWED_ORIGINS`
- `HLL_BACKEND_REFRESH_INTERVAL_SECONDS` - `HLL_BACKEND_REFRESH_INTERVAL_SECONDS`
El `frontend/index.html` viene preparado para volver a consultar el bloque de
servidores cada `60000` ms (`60s`) sin recargar la pagina completa. La landing
lee ese valor desde `data-server-refresh-ms`, por lo que puede ajustarse en el
HTML si una demo local necesita un intervalo distinto.
Valor por defecto de `HLL_BACKEND_ALLOWED_ORIGINS`: Valor por defecto de `HLL_BACKEND_ALLOWED_ORIGINS`:
- `null` - `null`
@@ -239,7 +244,7 @@ Ese comando ejecuta capturas persistidas de forma repetida usando el mismo
flujo del colector y la base SQLite local. Por defecto: flujo del colector y la base SQLite local. Por defecto:
- usa `--source auto` - usa `--source auto`
- espera `300` segundos entre ejecuciones - espera `60` segundos entre ejecuciones
- permite fallback controlado si A2S no responde - permite fallback controlado si A2S no responde
- sigue en ejecucion hasta que se detiene manualmente - sigue en ejecucion hasta que se detiene manualmente
@@ -259,6 +264,25 @@ python -m app.scheduler --interval 120
python -m app.scheduler --source a2s --no-fallback --max-runs 2 python -m app.scheduler --source a2s --no-fallback --max-runs 2
``` ```
Flujo local recomendado para ver datos vivos en la landing:
1. Desde `backend/`, arrancar la API:
```powershell
python -m app.main
```
2. En otra terminal, dejar el scheduler corriendo:
```powershell
python -m app.scheduler
```
3. Servir `frontend/` con un servidor local sencillo y abrir la landing. El
frontend volvera a pedir `/api/servers` y `/api/servers/latest` cada `60`
segundos, por lo que los cambios de mapa o poblacion apareceran sin recarga
manual cuando existan snapshots nuevos.
Este mecanismo deja el refresco desacoplado del servidor HTTP y es facil de Este mecanismo deja el refresco desacoplado del servidor HTTP y es facil de
reemplazar mas adelante por un scheduler mas serio sin rehacer el colector. reemplazar mas adelante por un scheduler mas serio sin rehacer el colector.

View File

@@ -9,7 +9,7 @@ from pathlib import Path
DEFAULT_HOST = "127.0.0.1" DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8000 DEFAULT_PORT = 8000
DEFAULT_STORAGE_FILENAME = "hll_vietnam_dev.sqlite3" DEFAULT_STORAGE_FILENAME = "hll_vietnam_dev.sqlite3"
DEFAULT_REFRESH_INTERVAL_SECONDS = 300 DEFAULT_REFRESH_INTERVAL_SECONDS = 60
DEFAULT_ALLOWED_ORIGINS = ( DEFAULT_ALLOWED_ORIGINS = (
"null", "null",
"http://127.0.0.1:5500", "http://127.0.0.1:5500",

View File

@@ -49,13 +49,13 @@ def run_local_refresh_loop(
def main() -> None: def main() -> None:
"""Allow local scheduled refresh execution without adding external infrastructure.""" """Allow local scheduled refresh execution without adding external infrastructure."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Run periodic local snapshot refreshes for development.", description="Run periodic local snapshot refreshes for development and landing demos.",
) )
parser.add_argument( parser.add_argument(
"--interval", "--interval",
type=int, type=int,
default=get_refresh_interval_seconds(), default=get_refresh_interval_seconds(),
help="Seconds to wait between persisted refresh runs.", help="Seconds to wait between persisted refresh runs. Defaults to env value or 60.",
) )
parser.add_argument( parser.add_argument(
"--source", "--source",

View File

@@ -14,9 +14,9 @@
--accent-warm: #d2b676; --accent-warm: #d2b676;
--shadow: 0 28px 72px rgba(0, 0, 0, 0.42); --shadow: 0 28px 72px rgba(0, 0, 0, 0.42);
--shadow-soft: 0 18px 40px rgba(0, 0, 0, 0.24); --shadow-soft: 0 18px 40px rgba(0, 0, 0, 0.24);
--hero-shell-width: 1240px; --page-shell-width: 1600px;
--video-shell-width: 1080px; --page-shell-gutter: 32px;
--servers-shell-width: 1380px; --panel-content-width: 1120px;
--font-main: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; --font-main: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
} }
@@ -35,10 +35,12 @@ body {
color: var(--text); color: var(--text);
background-color: var(--bg); background-color: var(--bg);
background: background:
linear-gradient(rgba(9, 11, 8, 0.62), rgba(9, 11, 8, 0.96)), linear-gradient(rgba(9, 11, 8, 0.56), rgba(9, 11, 8, 0.97)),
radial-gradient(circle at top, rgba(84, 96, 59, 0.2), transparent 34%), radial-gradient(circle at 18% 12%, rgba(108, 124, 75, 0.2), transparent 28%),
radial-gradient(circle at 85% 10%, rgba(210, 182, 118, 0.08), transparent 24%), radial-gradient(circle at top, rgba(84, 96, 59, 0.22), transparent 34%),
radial-gradient(circle at bottom, rgba(210, 182, 118, 0.07), transparent 28%), radial-gradient(circle at 85% 10%, rgba(210, 182, 118, 0.1), transparent 24%),
radial-gradient(circle at 78% 65%, rgba(54, 65, 42, 0.2), transparent 30%),
radial-gradient(circle at bottom, rgba(210, 182, 118, 0.08), transparent 28%),
linear-gradient(145deg, #171d15 0%, var(--bg-deep) 100%); linear-gradient(145deg, #171d15 0%, var(--bg-deep) 100%);
} }
@@ -53,20 +55,47 @@ a {
} }
.page-shell { .page-shell {
position: relative;
isolation: isolate;
width: 100%; width: 100%;
padding: 34px 20px 72px; padding: 30px 20px 72px;
}
.page-shell::before {
content: "";
position: absolute;
inset: 0;
z-index: -2;
background:
radial-gradient(circle at 12% 18%, rgba(210, 182, 118, 0.07), transparent 18%),
radial-gradient(circle at 82% 9%, rgba(142, 160, 98, 0.14), transparent 24%),
linear-gradient(180deg, rgba(255, 255, 255, 0.012), transparent 24%);
pointer-events: none;
}
.page-shell::after {
content: "";
position: absolute;
inset: 18px 4% auto;
z-index: -1;
height: 220px;
border-radius: 999px;
background: radial-gradient(circle, rgba(0, 0, 0, 0.36), transparent 72%);
filter: blur(18px);
pointer-events: none;
} }
.hero { .hero {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
width: min(100%, var(--hero-shell-width)); width: min(var(--page-shell-width), calc(100vw - (var(--page-shell-gutter) * 2)));
margin: 0 auto; margin: 0 auto;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 24px; border-radius: 24px;
background: background:
linear-gradient(180deg, rgba(24, 31, 20, 0.84), rgba(11, 14, 10, 0.97)), linear-gradient(180deg, rgba(26, 33, 21, 0.86), rgba(10, 13, 9, 0.98)),
radial-gradient(circle at top center, rgba(183, 201, 125, 0.06), transparent 32%), radial-gradient(circle at top center, rgba(183, 201, 125, 0.08), transparent 32%),
radial-gradient(circle at 82% 22%, rgba(210, 182, 118, 0.08), transparent 24%),
repeating-linear-gradient( repeating-linear-gradient(
90deg, 90deg,
rgba(255, 255, 255, 0.015) 0, rgba(255, 255, 255, 0.015) 0,
@@ -104,23 +133,37 @@ a {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: background:
radial-gradient(circle at top right, rgba(142, 160, 98, 0.16), transparent 28%), radial-gradient(circle at top right, rgba(142, 160, 98, 0.18), transparent 28%),
radial-gradient(circle at left center, rgba(210, 182, 118, 0.08), transparent 26%), radial-gradient(circle at left center, rgba(210, 182, 118, 0.1), transparent 26%),
radial-gradient(circle at 50% 115%, rgba(0, 0, 0, 0.42), transparent 34%),
linear-gradient(135deg, transparent 0%, rgba(0, 0, 0, 0.2) 100%), linear-gradient(135deg, transparent 0%, rgba(0, 0, 0, 0.2) 100%),
linear-gradient(180deg, rgba(0, 0, 0, 0) 55%, rgba(0, 0, 0, 0.28) 100%); linear-gradient(180deg, rgba(0, 0, 0, 0) 48%, rgba(0, 0, 0, 0.34) 100%);
pointer-events: none; pointer-events: none;
} }
.hero__content { .hero__content {
position: relative; position: relative;
z-index: 1; z-index: 1;
max-width: 860px; width: 100%;
max-width: none;
margin: 0 auto; margin: 0 auto;
padding: 68px 32px 78px; padding: 58px 52px 64px;
}
.hero__brand {
position: relative;
display: grid; display: grid;
justify-items: center; grid-template-columns: minmax(240px, 340px) minmax(0, 1fr);
text-align: center; align-items: center;
gap: 36px;
}
.hero__copy {
display: grid;
justify-items: start;
text-align: left;
gap: 18px; gap: 18px;
max-width: 660px;
} }
.hero__content::before { .hero__content::before {
@@ -140,9 +183,9 @@ a {
} }
.logo-frame { .logo-frame {
width: min(336px, 78vw); width: min(340px, 100%);
min-height: 220px; min-height: 248px;
padding: 20px 24px; padding: 24px 28px;
display: grid; display: grid;
place-items: center; place-items: center;
border: 1px dashed rgba(183, 201, 125, 0.45); border: 1px dashed rgba(183, 201, 125, 0.45);
@@ -180,7 +223,7 @@ a {
width: auto; width: auto;
height: auto; height: auto;
max-width: 100%; max-width: 100%;
max-height: 220px; max-height: 246px;
object-fit: contain; object-fit: contain;
filter: filter:
drop-shadow(0 10px 20px rgba(0, 0, 0, 0.28)) drop-shadow(0 10px 20px rgba(0, 0, 0, 0.28))
@@ -210,8 +253,8 @@ h2 {
} }
h1 { h1 {
max-width: 12ch; max-width: 10ch;
font-size: clamp(2.2rem, 5vw, 4.6rem); font-size: clamp(2.6rem, 5vw, 4.9rem);
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-shadow: 0 10px 28px rgba(0, 0, 0, 0.32); text-shadow: 0 10px 28px rgba(0, 0, 0, 0.32);
} }
@@ -226,26 +269,30 @@ h2 {
} }
.hero__text { .hero__text {
max-width: 640px;
margin: 0; margin: 0;
max-width: 56ch;
color: var(--text-soft); color: var(--text-soft);
font-size: 1.06rem; font-size: 1.03rem;
line-height: 1.85; line-height: 1.75;
} }
.hero__actions { .hero__actions {
display: grid; display: flex;
justify-items: center; flex-wrap: wrap;
gap: 14px; align-items: center;
margin-top: 4px; gap: 14px 16px;
margin-top: 2px;
} }
.status-chip { .status-chip {
margin: 0; margin: 0;
padding: 0.45rem 0.85rem; padding: 0.5rem 0.9rem;
border: 1px solid rgba(159, 168, 141, 0.22); border: 1px solid rgba(159, 168, 141, 0.22);
border-radius: 999px; border-radius: 999px;
background: rgba(21, 26, 19, 0.72); background: linear-gradient(180deg, rgba(28, 34, 26, 0.82), rgba(14, 18, 13, 0.86));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 10px 24px rgba(0, 0, 0, 0.18);
font-size: 0.76rem; font-size: 0.76rem;
letter-spacing: 0.12em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
@@ -294,22 +341,25 @@ h2 {
.content { .content {
width: 100%; width: 100%;
margin-top: -34px; margin-top: -28px;
position: relative; position: relative;
z-index: 2; z-index: 2;
display: grid; display: grid;
gap: 24px; gap: 30px;
} }
.panel { .panel {
position: relative; position: relative;
width: 100%; width: min(var(--page-shell-width), calc(100vw - (var(--page-shell-gutter) * 2)));
padding: 32px; margin: 0 auto;
padding: 34px 40px 38px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 24px; border-radius: 24px;
background: background:
linear-gradient(180deg, rgba(23, 28, 21, 0.96), rgba(13, 16, 12, 0.99)); linear-gradient(180deg, rgba(25, 31, 23, 0.96), rgba(12, 15, 11, 0.995));
box-shadow: var(--shadow); box-shadow:
0 34px 82px rgba(0, 0, 0, 0.34),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
} }
.panel::before { .panel::before {
@@ -321,10 +371,26 @@ h2 {
background: linear-gradient(90deg, rgba(210, 182, 118, 0.7), transparent); background: linear-gradient(90deg, rgba(210, 182, 118, 0.7), transparent);
} }
.panel::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background:
radial-gradient(circle at top center, rgba(183, 201, 125, 0.07), transparent 30%),
linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent 18%);
pointer-events: none;
}
.panel__header { .panel__header {
margin-bottom: 22px; margin-bottom: 22px;
} }
.panel__shell {
width: 100%;
margin: 0 auto;
}
.panel__header--servers { .panel__header--servers {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -344,99 +410,11 @@ h2 {
max-width: 58ch; max-width: 58ch;
} }
.servers-source {
margin-bottom: 20px;
padding: 14px 16px;
border: 1px solid rgba(159, 168, 141, 0.18);
border-radius: 16px;
background: rgba(10, 12, 9, 0.26);
}
.servers-source[data-state="live"] {
border-color: rgba(183, 201, 125, 0.3);
background: rgba(142, 160, 98, 0.08);
}
.servers-source[data-state="historical"] {
border-color: rgba(210, 182, 118, 0.24);
background: rgba(210, 182, 118, 0.06);
}
.servers-source__label,
.servers-source__meta {
margin: 0;
}
.servers-source__label {
color: var(--text);
font-size: 0.82rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.servers-source__meta {
margin-top: 8px;
color: var(--text-soft);
line-height: 1.6;
}
.stats-preview {
margin-bottom: 20px;
padding: 16px 18px;
border: 1px solid rgba(159, 168, 141, 0.18);
border-radius: 18px;
background:
linear-gradient(180deg, rgba(28, 34, 25, 0.92), rgba(14, 18, 13, 0.95));
}
.stats-preview__eyebrow {
margin: 0 0 14px;
color: var(--accent-warm);
font-size: 0.74rem;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.stats-preview__items {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.stats-preview__item {
padding: 14px;
border: 1px solid rgba(159, 168, 141, 0.14);
border-radius: 14px;
background: rgba(10, 12, 9, 0.34);
}
.stats-preview__item p {
margin: 0 0 8px;
color: var(--muted);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.stats-preview__item strong {
display: block;
font-size: 1rem;
color: var(--text);
}
.panel--video { .panel--video {
position: relative; position: relative;
max-width: var(--video-shell-width);
margin: 0 auto;
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
.panel--servers {
max-width: var(--servers-shell-width);
margin: 0 auto;
padding-inline: 38px;
}
.video-wrapper { .video-wrapper {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -482,55 +460,22 @@ h2 {
.servers-grid { .servers-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
gap: 20px; gap: 24px;
} }
.servers-grid--section { .servers-grid--section {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
align-items: stretch; align-items: stretch;
} }
.server-panel-section + .server-panel-section {
margin-top: 22px;
}
.server-panel-section__header {
margin-bottom: 14px;
padding: 0 2px;
}
.server-panel-section__header h3,
.server-panel-section__header p {
margin: 0;
}
.server-panel-section__header h3 {
font-size: 1rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.server-panel-section__header p {
margin-top: 8px;
color: var(--text-soft);
line-height: 1.6;
max-width: 62ch;
}
.server-panel-section--real .server-panel-section__header h3 {
color: var(--accent-strong);
}
.server-panel-section--reference .server-panel-section__header h3 {
color: var(--accent-warm);
}
.server-card { .server-card {
position: relative;
overflow: hidden;
min-width: 0; min-width: 0;
padding: 20px; padding: 22px;
border: 1px solid rgba(159, 168, 141, 0.18); border: 1px solid rgba(159, 168, 141, 0.18);
border-radius: 20px; border-radius: 22px;
background: background:
linear-gradient(180deg, rgba(28, 34, 25, 0.94), rgba(15, 18, 13, 0.98)); linear-gradient(180deg, rgba(28, 34, 25, 0.94), rgba(15, 18, 13, 0.98));
box-shadow: box-shadow:
@@ -538,6 +483,24 @@ h2 {
var(--shadow-soft); var(--shadow-soft);
} }
.server-card::before {
content: "";
position: absolute;
inset: 0 0 auto;
height: 1px;
background: linear-gradient(90deg, rgba(210, 182, 118, 0.4), transparent 68%);
pointer-events: none;
}
.server-card::after {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(circle at top right, rgba(183, 201, 125, 0.06), transparent 28%);
pointer-events: none;
}
.server-card--real { .server-card--real {
border-color: rgba(183, 201, 125, 0.24); border-color: rgba(183, 201, 125, 0.24);
background: background:
@@ -572,7 +535,7 @@ h2 {
.server-card__eyebrow { .server-card__eyebrow {
margin: 0; margin: 0;
color: var(--accent-warm); color: var(--accent-warm);
font-size: 0.72rem; font-size: 0.68rem;
letter-spacing: 0.14em; letter-spacing: 0.14em;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -585,14 +548,6 @@ h2 {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.server-card__meta {
margin: 0 0 14px;
color: var(--muted);
font-size: 0.8rem;
letter-spacing: 0.04em;
line-height: 1.6;
}
.server-card__status-column { .server-card__status-column {
display: grid; display: grid;
align-content: start; align-content: start;
@@ -606,14 +561,36 @@ h2 {
padding: 10px 12px; padding: 10px 12px;
max-width: 100%; max-width: 100%;
min-width: 120px; min-width: 120px;
border: 1px solid rgba(159, 168, 141, 0.16); border: 1px solid rgba(159, 168, 141, 0.18);
border-radius: 14px; border-radius: 14px;
background: rgba(10, 12, 9, 0.3); background: linear-gradient(180deg, rgba(12, 15, 10, 0.54), rgba(9, 11, 8, 0.34));
color: var(--accent-strong); color: var(--accent-strong);
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-align: center; text-align: center;
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 { .server-card__actions {
@@ -658,13 +635,14 @@ h2 {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 88px; min-width: 88px;
padding: 0.35rem 0.65rem; padding: 0.38rem 0.72rem;
border: 1px solid rgba(159, 168, 141, 0.22); border: 1px solid rgba(159, 168, 141, 0.22);
border-radius: 999px; border-radius: 999px;
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.1em; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
} }
.server-state--online { .server-state--online {
@@ -679,34 +657,18 @@ h2 {
color: var(--accent-warm); color: var(--accent-warm);
} }
.server-card__load {
width: 100%;
height: 8px;
margin-top: 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
overflow: hidden;
}
.server-card__load span {
display: block;
height: 100%;
border-radius: inherit;
background:
linear-gradient(90deg, rgba(210, 182, 118, 0.78), rgba(142, 160, 98, 0.92));
box-shadow: 0 0 18px rgba(142, 160, 98, 0.22);
}
.server-card__stats { .server-card__stats {
margin: 0; margin: 0;
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px; gap: 12px;
} }
.server-stat { .server-stat {
padding-top: 14px; padding: 14px 14px 15px;
border-top: 1px solid rgba(159, 168, 141, 0.12); 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 { .server-stat--players dd {
@@ -727,20 +689,6 @@ h2 {
line-height: 1.4; line-height: 1.4;
} }
.server-card__body {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(220px, 0.85fr);
gap: 16px;
align-items: start;
}
.server-card__facts {
min-width: 0;
display: grid;
gap: 14px;
}
.server-card__quickfacts { .server-card__quickfacts {
margin-bottom: 0; margin-bottom: 0;
display: grid; display: grid;
@@ -751,9 +699,9 @@ h2 {
.server-card__quickfact { .server-card__quickfact {
min-width: 0; min-width: 0;
padding: 12px 14px; padding: 12px 14px;
border: 1px solid rgba(159, 168, 141, 0.12); border: 1px solid rgba(159, 168, 141, 0.14);
border-radius: 14px; border-radius: 14px;
background: rgba(10, 12, 9, 0.22); background: linear-gradient(180deg, rgba(12, 15, 11, 0.34), rgba(8, 10, 7, 0.2));
} }
.server-card__quickfact p, .server-card__quickfact p,
@@ -777,96 +725,6 @@ h2 {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.server-summary {
margin: 0;
padding: 16px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
border: 1px solid rgba(159, 168, 141, 0.12);
border-radius: 16px;
background: rgba(10, 12, 9, 0.26);
}
.server-summary__item {
min-width: 0;
}
.server-summary__item dt,
.server-summary__item dd {
margin: 0;
}
.server-summary__item dt {
margin-bottom: 6px;
color: var(--muted);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.server-summary__item dd {
color: var(--text);
font-size: 0.92rem;
line-height: 1.4;
overflow-wrap: anywhere;
}
.server-trend {
margin-top: 0;
min-width: 0;
padding: 16px;
border: 1px solid rgba(159, 168, 141, 0.12);
border-radius: 16px;
background: rgba(10, 12, 9, 0.26);
}
.server-trend__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.server-trend__header p,
.server-trend__header span {
margin: 0;
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.server-trend__header p {
color: var(--text-soft);
}
.server-trend__header span {
color: var(--muted);
}
.server-trend__bars {
display: flex;
align-items: flex-end;
gap: 8px;
min-height: 70px;
overflow: hidden;
}
.server-trend__bar {
flex: 1 1 0;
min-width: 0;
border-radius: 999px 999px 4px 4px;
background:
linear-gradient(180deg, rgba(210, 182, 118, 0.86), rgba(142, 160, 98, 0.92));
box-shadow: 0 0 18px rgba(142, 160, 98, 0.18);
}
.server-trend__bar--empty {
height: 14%;
opacity: 0.35;
}
.servers-empty { .servers-empty {
margin: 0; margin: 0;
padding: 18px; padding: 18px;
@@ -877,12 +735,23 @@ h2 {
} }
@media (max-width: 1120px) { @media (max-width: 1120px) {
.servers-grid--section { .hero__brand {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); grid-template-columns: 1fr;
justify-items: center;
gap: 24px;
} }
.server-card__body { .hero__copy {
grid-template-columns: 1fr; justify-items: center;
text-align: center;
}
.hero__actions {
justify-content: center;
}
.servers-grid--section {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
} }
} }
@@ -893,25 +762,17 @@ h2 {
} }
} }
@media (max-width: 960px) {
.server-card__body {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.page-shell { .page-shell {
padding: 18px 14px 40px; padding: 18px 14px 40px;
} }
.hero__content, .hero__content {
.panel { padding: 42px 18px 48px;
padding: 24px 16px;
} }
.hero__content { .panel {
padding-top: 44px; padding: 24px 16px;
padding-bottom: 52px;
} }
.hero__content::before { .hero__content::before {
@@ -932,6 +793,16 @@ h2 {
max-height: 180px; max-height: 180px;
} }
h1 {
max-width: 11ch;
font-size: clamp(2.2rem, 10vw, 3.2rem);
}
.hero__actions {
width: 100%;
justify-content: center;
}
.discord-button { .discord-button {
width: 100%; width: 100%;
} }
@@ -973,14 +844,6 @@ h2 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.server-summary {
grid-template-columns: 1fr;
}
.stats-preview__items {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.panel::before { .panel::before {
left: 16px; left: 16px;
} }

View File

@@ -1,11 +1,15 @@
// Progressive enhancement for local frontend-backend checks. // Progressive enhancement for local frontend-backend checks.
const RECENT_SNAPSHOT_WINDOW_MS = 30 * 60 * 1000; const RECENT_SNAPSHOT_WINDOW_MS = 30 * 60 * 1000;
const DEFAULT_SERVER_POLL_INTERVAL_MS = 60 * 1000;
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
console.info("HLL Vietnam frontend ready"); console.info("HLL Vietnam frontend ready");
const backendBaseUrl = const backendBaseUrl =
document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000"; document.body.dataset.backendBaseUrl || "http://127.0.0.1:8000";
const serverPollIntervalMs = getServerPollIntervalMs(
document.body.dataset.serverRefreshMs,
);
const statusNode = document.getElementById("backend-status"); const statusNode = document.getElementById("backend-status");
const trailerFrame = document.getElementById("trailer-frame"); const trailerFrame = document.getElementById("trailer-frame");
const trailerTitle = document.getElementById("trailer-title"); const trailerTitle = document.getElementById("trailer-title");
@@ -13,42 +17,44 @@ document.addEventListener("DOMContentLoaded", () => {
const serversNote = document.getElementById("servers-note"); const serversNote = document.getElementById("servers-note");
const serversList = document.getElementById("servers-list"); const serversList = document.getElementById("servers-list");
const serversBadge = document.getElementById("servers-badge"); const serversBadge = document.getElementById("servers-badge");
const serversSource = document.getElementById("servers-source");
const serversSourceLabel = document.getElementById("servers-source-label");
const serversSourceMeta = document.getElementById("servers-source-meta");
const statsPreview = document.getElementById("stats-preview");
const statsPreviewItems = document.getElementById("stats-preview-items");
updateBackendStatus(statusNode, "Backend comprobando", "status-chip--idle"); updateBackendStatus(statusNode, "Backend comprobando", "status-chip--idle");
setServersDataState( setServersDataState(serversBadge, { kind: "fallback" });
{
badgeNode: serversBadge, let serverRefreshInFlight = false;
sourceNode: serversSource, const refreshServers = async () => {
labelNode: serversSourceLabel, if (serverRefreshInFlight) {
metaNode: serversSourceMeta, return;
}, }
{ kind: "fallback" },
); serverRefreshInFlight = true;
try {
await hydrateServers(
backendBaseUrl,
serversTitle,
serversNote,
serversList,
serversBadge,
);
} finally {
serverRefreshInFlight = false;
}
};
Promise.allSettled([ Promise.allSettled([
fetchHealth(backendBaseUrl, statusNode), fetchHealth(backendBaseUrl, statusNode),
hydrateTrailer(backendBaseUrl, trailerFrame, trailerTitle), hydrateTrailer(backendBaseUrl, trailerFrame, trailerTitle),
hydrateServers( refreshServers(),
backendBaseUrl,
serversTitle,
serversNote,
serversList,
serversBadge,
serversSource,
serversSourceLabel,
serversSourceMeta,
statsPreview,
statsPreviewItems,
),
]).catch((error) => { ]).catch((error) => {
console.warn("Progressive enhancement failed", error); console.warn("Progressive enhancement failed", error);
}); });
if (serverPollIntervalMs > 0) {
window.setInterval(() => {
void refreshServers();
}, serverPollIntervalMs);
}
}); });
async function fetchHealth(backendBaseUrl, statusNode) { async function fetchHealth(backendBaseUrl, statusNode) {
@@ -102,11 +108,6 @@ async function hydrateServers(
serversNote, serversNote,
serversList, serversList,
serversBadge, serversBadge,
serversSource,
serversSourceLabel,
serversSourceMeta,
statsPreview,
statsPreviewItems,
) { ) {
if (!serversTitle || !serversNote || !serversList || !serversBadge) { if (!serversTitle || !serversNote || !serversList || !serversBadge) {
return; return;
@@ -125,20 +126,12 @@ async function hydrateServers(
} }
serversTitle.textContent = serversTitle.textContent =
serversData.title || "Servidores actuales de Hell Let Loose"; serversData.title || "Estado actual de servidores";
setServersDataState( setServersDataState(serversBadge, { kind: "fallback" });
{
badgeNode: serversBadge,
sourceNode: serversSource,
labelNode: serversSourceLabel,
metaNode: serversSourceMeta,
},
{ kind: "fallback" },
);
if (serversData.context === "current-hll-reference") { if (serversData.context === "current-hll-reference") {
serversNote.textContent = serversNote.textContent =
"Referencia provisional del HLL actual mientras no existan datos reales de HLL Vietnam."; "Referencia actual de servidores de Hell Let Loose.";
} }
if (serversData.items.length === 0) { if (serversData.items.length === 0) {
@@ -154,11 +147,6 @@ async function hydrateServers(
serversNote, serversNote,
serversList, serversList,
serversBadge, serversBadge,
serversSource,
serversSourceLabel,
serversSourceMeta,
statsPreview,
statsPreviewItems,
); );
} catch (error) { } catch (error) {
console.warn("Servers panel remains on static fallback", error); console.warn("Servers panel remains on static fallback", error);
@@ -171,16 +159,7 @@ async function hydrateServerStats(
serversNote, serversNote,
serversList, serversList,
serversBadge, serversBadge,
serversSource,
serversSourceLabel,
serversSourceMeta,
statsPreview,
statsPreviewItems,
) { ) {
if (!statsPreview || !statsPreviewItems) {
return;
}
try { try {
const latestPayload = await fetchJson(`${backendBaseUrl}/api/servers/latest`); const latestPayload = await fetchJson(`${backendBaseUrl}/api/servers/latest`);
const latestItems = latestPayload?.data?.items; const latestItems = latestPayload?.data?.items;
@@ -188,48 +167,20 @@ async function hydrateServerStats(
return; return;
} }
const histories = await Promise.all(
latestItems.map(async (server) => {
const serverKey = server.external_server_id || server.server_id;
const historyPayload = await fetchJson(
`${backendBaseUrl}/api/servers/${encodeURIComponent(serverKey)}/history?limit=4`,
);
return {
key: serverKey,
items: Array.isArray(historyPayload?.data?.items)
? historyPayload.data.items
: [],
};
}),
);
const historyByServer = new Map(
histories.map((entry) => [String(entry.key), entry.items]),
);
const visibleItems = selectPrimaryServerItems(latestItems); const visibleItems = selectPrimaryServerItems(latestItems);
const latestState = deriveSnapshotState(visibleItems); const latestState = deriveSnapshotState(visibleItems);
const hasRealSnapshots = visibleItems.some(isRealA2SSnapshot); const hasRealSnapshots = visibleItems.some(isRealA2SSnapshot);
serversTitle.textContent = hasRealSnapshots serversTitle.textContent = hasRealSnapshots
? "Servidores activos con captura real" ? "Servidores disponibles"
: latestPayload.data.title || "Actividad reciente de servidores"; : latestPayload.data.title || "Actividad reciente de servidores";
serversNote.textContent = hasRealSnapshots serversNote.textContent = hasRealSnapshots
? "La vista principal muestra solo servidores con snapshots reales A2S utilizables. El fallback provisional queda reservado para cuando el backend no aporta capturas validas." ? "Estado actual y acceso directo a los servidores disponibles."
: "Vista ligera basada en snapshots persistidos del backend. Si no hay capturas reales utilizables, el panel conserva la referencia provisional."; : "Ultimo estado disponible de servidores.";
setServersDataState( setServersDataState(serversBadge, latestState);
{ serversList.innerHTML = renderServerSections(visibleItems);
badgeNode: serversBadge,
sourceNode: serversSource,
labelNode: serversSourceLabel,
metaNode: serversSourceMeta,
},
latestState,
);
statsPreview.hidden = false;
statsPreviewItems.innerHTML = renderStatsPreview(visibleItems);
serversList.innerHTML = renderServerSections(visibleItems, historyByServer);
} catch (error) { } catch (error) {
console.warn("Historical stats preview unavailable", error); console.warn("Historical server enrichment unavailable", error);
} }
} }
@@ -245,45 +196,28 @@ function updateBackendStatus(statusNode, label, stateClass) {
} }
} }
function setServersDataState(nodes, state) { function setServersDataState(badgeNode, state) {
const { badgeNode, sourceNode, labelNode, metaNode } = nodes; if (!badgeNode) {
if (!badgeNode || !sourceNode || !labelNode || !metaNode) {
return; return;
} }
if (state.kind === "live") { if (state.kind === "live") {
badgeNode.textContent = "Snapshots A2S recientes"; badgeNode.textContent = "Datos en vivo";
labelNode.textContent = "Captura real reciente";
metaNode.textContent =
state.timestampLabel
? `Ultima captura A2S registrada ${state.timestampLabel}.`
: "El bloque muestra snapshots recientes procedentes del backend.";
badgeNode.classList.remove("status-chip--fallback"); badgeNode.classList.remove("status-chip--fallback");
badgeNode.classList.add("status-chip--ok"); badgeNode.classList.add("status-chip--ok");
sourceNode.dataset.state = "live";
return; return;
} }
if (state.kind === "historical") { if (state.kind === "historical") {
badgeNode.textContent = "Historico persistido"; badgeNode.textContent = "Actividad reciente";
labelNode.textContent = "Persistencia local disponible";
metaNode.textContent =
state.timestampLabel
? `Se muestran snapshots guardados. Ultima captura ${state.timestampLabel}.`
: "El backend devuelve historico persistido, aunque no sea una captura reciente.";
badgeNode.classList.remove("status-chip--fallback"); badgeNode.classList.remove("status-chip--fallback");
badgeNode.classList.add("status-chip--ok"); badgeNode.classList.add("status-chip--ok");
sourceNode.dataset.state = "historical";
return; return;
} }
badgeNode.textContent = "Fallback estatico"; badgeNode.textContent = "Referencia actual";
labelNode.textContent = "Fallback estatico activo";
metaNode.textContent =
"La landing conserva la referencia provisional cuando el backend o el historico aun no aportan snapshots utilizables.";
badgeNode.classList.remove("status-chip--ok"); badgeNode.classList.remove("status-chip--ok");
badgeNode.classList.add("status-chip--fallback"); badgeNode.classList.add("status-chip--fallback");
sourceNode.dataset.state = "fallback";
} }
function renderServerCard(server) { function renderServerCard(server) {
@@ -296,20 +230,23 @@ function renderServerCard(server) {
const region = server.region || "Region pendiente"; const region = server.region || "Region pendiente";
const players = Number.isFinite(server.players) ? server.players : 0; const players = Number.isFinite(server.players) ? server.players : 0;
const maxPlayers = Number.isFinite(server.max_players) ? server.max_players : 0; const maxPlayers = Number.isFinite(server.max_players) ? server.max_players : 0;
const loadPercent = const occupancy = getPopulationPercent(players, maxPlayers);
maxPlayers > 0 ? Math.max(0, Math.min(100, Math.round((players / maxPlayers) * 100))) : 0; const sourceLabel = isRealA2SSnapshot(server) ? "Snapshot activo" : "Referencia actual";
return ` return `
<article class="server-card"> <article class="server-card">
<div class="server-card__top"> <div class="server-card__top">
<div class="server-card__identity"> <div class="server-card__identity">
<p class="server-card__eyebrow">Servidor de referencia</p> <p class="server-card__eyebrow">${escapeHtml(sourceLabel)}</p>
<h3>${escapeHtml(serverName)}</h3> <h3>${escapeHtml(serverName)}</h3>
</div> </div>
<span class="server-state ${stateClass}">${escapeHtml(serverStatus)}</span> <div class="server-card__status-column">
<span class="server-state ${stateClass}">${escapeHtml(serverStatus)}</span>
<p class="server-card__population">${players} / ${maxPlayers}</p>
</div>
</div> </div>
<div class="server-card__load" aria-hidden="true"> <div class="server-card__load" aria-hidden="true">
<span style="width: ${loadPercent}%"></span> <span style="width: ${occupancy}%"></span>
</div> </div>
<dl class="server-card__stats"> <dl class="server-card__stats">
<div class="server-stat server-stat--players"> <div class="server-stat server-stat--players">
@@ -329,7 +266,7 @@ function renderServerCard(server) {
`; `;
} }
function renderServerStatsCard(server, historyItems) { function renderServerStatsCard(server) {
const serverName = server.server_name || "Servidor sin nombre"; const serverName = server.server_name || "Servidor sin nombre";
const statusLabel = formatServerStatus(server.status); const statusLabel = formatServerStatus(server.status);
const stateClass = const stateClass =
@@ -339,17 +276,13 @@ function renderServerStatsCard(server, historyItems) {
const region = server.region || "Region pendiente"; const region = server.region || "Region pendiente";
const players = Number.isFinite(server.players) ? server.players : 0; const players = Number.isFinite(server.players) ? server.players : 0;
const maxPlayers = Number.isFinite(server.max_players) ? server.max_players : 0; const maxPlayers = Number.isFinite(server.max_players) ? server.max_players : 0;
const loadPercent = const occupancy = getPopulationPercent(players, maxPlayers);
maxPlayers > 0 ? Math.max(0, Math.min(100, Math.round((players / maxPlayers) * 100))) : 0;
const updatedAt = server.captured_at const updatedAt = server.captured_at
? formatTimestamp(server.captured_at) ? formatTimestamp(server.captured_at)
: "Sin captura reciente"; : "Sin captura reciente";
const updatedAgo = formatElapsedMinutes(server.history_summary?.minutes_since_last_capture); const updatedAgo = formatElapsedMinutes(server.history_summary?.minutes_since_last_capture);
const trendMarkup = renderTrend(historyItems);
const connectAction = renderConnectAction(server); const connectAction = renderConnectAction(server);
const summaryMarkup = renderHistorySummary(server.history_summary);
const cardVariantClass = isRealSnapshot ? "server-card--real" : "server-card--reference"; const cardVariantClass = isRealSnapshot ? "server-card--real" : "server-card--reference";
const eyebrowLabel = isRealSnapshot ? "Snapshot real A2S" : "Referencia persistida";
const quickFacts = renderQuickFacts([ const quickFacts = renderQuickFacts([
{ label: "Mapa", value: currentMap }, { label: "Mapa", value: currentMap },
{ label: "Region", value: region }, { label: "Region", value: region },
@@ -360,9 +293,8 @@ function renderServerStatsCard(server, historyItems) {
<article class="server-card server-card--stats ${cardVariantClass}"> <article class="server-card server-card--stats ${cardVariantClass}">
<div class="server-card__top server-card__top--stats"> <div class="server-card__top server-card__top--stats">
<div class="server-card__identity"> <div class="server-card__identity">
<p class="server-card__eyebrow">${escapeHtml(eyebrowLabel)}</p> <p class="server-card__eyebrow">${isRealSnapshot ? "Snapshot activo" : "Referencia actual"}</p>
<h3>${escapeHtml(serverName)}</h3> <h3>${escapeHtml(serverName)}</h3>
<p class="server-card__meta">Mapa actual, region visible y ultima captura persistida del servidor.</p>
</div> </div>
<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>
@@ -370,60 +302,19 @@ function renderServerStatsCard(server, historyItems) {
${connectAction} ${connectAction}
</div> </div>
</div> </div>
<div class="server-card__body">
<div class="server-card__facts">
${quickFacts}
${summaryMarkup}
</div>
<div class="server-trend">
<div class="server-trend__header">
<p>Tendencia reciente</p>
<span>${historyItems.length} capturas</span>
</div>
<div class="server-trend__bars" aria-hidden="true">${trendMarkup}</div>
</div>
</div>
<div class="server-card__load" aria-hidden="true"> <div class="server-card__load" aria-hidden="true">
<span style="width: ${loadPercent}%"></span> <span style="width: ${occupancy}%"></span>
</div> </div>
${quickFacts}
</article> </article>
`; `;
} }
function renderServerSections(latestItems, historyByServer) { function renderServerSections(latestItems) {
const hasRealSnapshots = latestItems.some(isRealA2SSnapshot);
return renderServerSection(
hasRealSnapshots ? "Snapshots reales A2S" : "Historico persistido",
hasRealSnapshots
? "Servidores reales validados y consultados desde el backend."
: "Snapshots persistidos disponibles mientras no haya capturas reales utilizables.",
hasRealSnapshots ? "server-panel-section--real" : "server-panel-section--reference",
latestItems,
historyByServer,
);
}
function renderServerSection(title, intro, variantClass, items, historyByServer) {
const cards = items
.map((server) =>
renderServerStatsCard(
server,
historyByServer.get(String(server.external_server_id || server.server_id)) || [],
),
)
.join("");
return ` return `
<section class="server-panel-section ${variantClass}"> <div class="servers-grid servers-grid--section">
<div class="server-panel-section__header"> ${latestItems.map((server) => renderServerStatsCard(server)).join("")}
<h3>${escapeHtml(title)}</h3> </div>
<p>${escapeHtml(intro)}</p>
</div>
<div class="servers-grid servers-grid--section">
${cards}
</div>
</section>
`; `;
} }
@@ -446,56 +337,6 @@ function renderConnectAction(server) {
`; `;
} }
function renderStatsPreview(items) {
const latestTimestamp = items
.map((item) => item.captured_at)
.filter(Boolean)
.sort()
.at(-1);
const totalPlayers = items.reduce(
(sum, item) => sum + (Number.isFinite(item.players) ? item.players : 0),
0,
);
const onlineServers = items.filter((item) => item.status === "online").length;
return [
renderStatsPreviewItem("Servidores", String(items.length)),
renderStatsPreviewItem("Online", String(onlineServers)),
renderStatsPreviewItem("Jugadores", String(totalPlayers)),
renderStatsPreviewItem(
"Ultima captura",
latestTimestamp ? formatTimestamp(latestTimestamp) : "Pendiente",
),
].join("");
}
function renderHistorySummary(summary) {
if (!summary) {
return "";
}
return `
<dl class="server-summary">
<div class="server-summary__item">
<dt>Visto online</dt>
<dd>${escapeHtml(summary.last_seen_online_at ? formatTimestamp(summary.last_seen_online_at) : "Sin registro")}</dd>
</div>
<div class="server-summary__item">
<dt>Capturas</dt>
<dd>${escapeHtml(`${summary.recent_capture_count || 0}/${summary.window_size || 0}`)}</dd>
</div>
<div class="server-summary__item">
<dt>Promedio</dt>
<dd>${escapeHtml(formatPlayerMetric(summary.recent_average_players))}</dd>
</div>
<div class="server-summary__item">
<dt>Pico</dt>
<dd>${escapeHtml(formatPlayerMetric(summary.recent_peak_players))}</dd>
</div>
</dl>
`;
}
function renderQuickFacts(items) { function renderQuickFacts(items) {
return ` return `
<div class="server-card__quickfacts"> <div class="server-card__quickfacts">
@@ -547,32 +388,6 @@ function deriveSnapshotState(items) {
return { kind: "historical", timestampLabel }; return { kind: "historical", timestampLabel };
} }
function renderStatsPreviewItem(label, value) {
return `
<article class="stats-preview__item">
<p>${escapeHtml(label)}</p>
<strong>${escapeHtml(value)}</strong>
</article>
`;
}
function renderTrend(historyItems) {
if (!Array.isArray(historyItems) || historyItems.length === 0) {
return '<span class="server-trend__bar server-trend__bar--empty"></span>';
}
const orderedItems = [...historyItems].reverse();
return orderedItems
.map((item) => {
const players = Number.isFinite(item.players) ? item.players : 0;
const maxPlayers = Number.isFinite(item.max_players) ? item.max_players : 0;
const height =
maxPlayers > 0 ? Math.max(14, Math.round((players / maxPlayers) * 100)) : 14;
return `<span class="server-trend__bar" style="height: ${height}%"></span>`;
})
.join("");
}
function formatServerStatus(status) { function formatServerStatus(status) {
if (status === "online") { if (status === "online") {
return "Online"; return "Online";
@@ -619,8 +434,21 @@ function formatElapsedMinutes(minutes) {
return `hace ${days} d`; return `hace ${days} d`;
} }
function formatPlayerMetric(value) { function getPopulationPercent(players, maxPlayers) {
return Number.isFinite(value) ? String(value) : "Sin dato"; 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) {
return DEFAULT_SERVER_POLL_INTERVAL_MS;
}
return parsedValue;
} }
async function fetchJson(url) { async function fetchJson(url) {

View File

@@ -10,154 +10,152 @@
<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" />
</head> </head>
<body data-backend-base-url="http://127.0.0.1:8000"> <body
data-backend-base-url="http://127.0.0.1:8000"
data-server-refresh-ms="60000"
>
<div class="page-shell"> <div class="page-shell">
<header class="hero"> <header class="hero">
<div class="hero__overlay"></div> <div class="hero__overlay"></div>
<div class="hero__content"> <div class="hero__content">
<div class="logo-frame"> <div class="hero__brand">
<img <div class="logo-frame">
src="./assets/img/logo.png" <img
alt="Logo oficial de la comunidad HLL Vietnam" src="./assets/img/logo.png"
class="logo-frame__image" alt="Logo oficial de la comunidad HLL Vietnam"
width="1024" class="logo-frame__image"
height="1044" width="1024"
decoding="async" height="1044"
/> decoding="async"
</div> />
<p class="eyebrow">Comunidad táctica hispana</p> </div>
<h1 class="hero__title"> <div class="hero__copy">
Comunidad Hispana <p class="eyebrow">Comunidad táctica hispana</p>
<span class="hero__title-accent">HLL Vietnam</span> <h1 class="hero__title">
</h1> Comunidad Hispana
<p class="hero__text"> <span class="hero__title-accent">HLL Vietnam</span>
Punto de encuentro para jugadores, escuadras y comunidad alrededor </h1>
del futuro universo HLL Vietnam. <p class="hero__text">
</p> Centro de reunión para escuadras, eventos y acceso directo al
<div class="hero__actions"> Discord oficial de la comunidad.
<p </p>
class="status-chip status-chip--idle" <div class="hero__actions">
id="backend-status" <p
aria-live="polite" class="status-chip status-chip--idle"
> id="backend-status"
Backend no verificado aria-live="polite"
</p> >
<a Backend no verificado
class="discord-button" </p>
href="https://discord.com/invite/PedEqZ2Xsa" <a
target="_blank" class="discord-button"
rel="noreferrer" href="https://discord.com/invite/PedEqZ2Xsa"
> target="_blank"
Unirse al Discord rel="noreferrer"
</a> >
Unirse al Discord
</a>
</div>
</div>
</div> </div>
</div> </div>
</header> </header>
<main class="content"> <main class="content">
<section class="panel panel--video"> <section class="panel panel--video">
<div class="panel__header"> <div class="panel__shell">
<p class="eyebrow eyebrow--section">Trailer</p> <div class="panel__header">
<h2 id="trailer-title">Primer vistazo a HLL Vietnam</h2> <p class="eyebrow eyebrow--section">Trailer</p>
<p class="panel__intro panel__intro--tight"> <h2 id="trailer-title">Primer vistazo a HLL Vietnam</h2>
El bloque principal se apoya en una presentacion limpia del </div>
trailer para reforzar el tono tactico y cinematografico del <div class="video-wrapper">
proyecto. <iframe
</p> id="trailer-frame"
</div> src="https://www.youtube.com/embed/JzYzYNVWZ_A"
<div class="video-wrapper"> title="Trailer HLL Vietnam"
<iframe loading="lazy"
id="trailer-frame" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
src="https://www.youtube.com/embed/JzYzYNVWZ_A" referrerpolicy="strict-origin-when-cross-origin"
title="Trailer HLL Vietnam" allowfullscreen
loading="lazy" ></iframe>
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" </div>
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div> </div>
</section> </section>
<section class="panel panel--servers" aria-labelledby="servers-title"> <section class="panel panel--servers" aria-labelledby="servers-title">
<div class="panel__header panel__header--servers"> <div class="panel__shell">
<div> <div class="panel__header panel__header--servers">
<p class="eyebrow eyebrow--section">Actividad de servidores</p> <div>
<h2 id="servers-title">Servidores actuales de Hell Let Loose</h2> <p class="eyebrow eyebrow--section">Servidores</p>
<h2 id="servers-title">Estado actual de servidores</h2>
</div>
<p class="status-chip status-chip--fallback" id="servers-badge">
Referencia actual
</p>
</div> </div>
<p class="status-chip status-chip--fallback" id="servers-badge"> <p class="panel__intro" id="servers-note">
Fallback estatico Estado actual y acceso directo a los servidores disponibles.
</p> </p>
</div> <div class="servers-grid" id="servers-list">
<p class="panel__intro" id="servers-note"> <article class="server-card">
El panel prioriza snapshots reales capturados por el backend y <div class="server-card__top">
conserva un fallback provisional solo cuando no hay datos utilizables. <div class="server-card__identity">
</p> <p class="server-card__eyebrow">Referencia actual</p>
<div class="servers-source" id="servers-source" data-state="fallback"> <h3>HLL ESP Tactical Rotation</h3>
<p class="servers-source__label" id="servers-source-label"> </div>
Fallback estatico activo <div class="server-card__status-column">
</p> <span class="server-state server-state--online">Online</span>
<p class="servers-source__meta" id="servers-source-meta"> <p class="server-card__population">74 / 100</p>
La landing conserva la referencia provisional cuando el backend o </div>
el historico aun no aportan snapshots utilizables.
</p>
</div>
<div class="stats-preview" id="stats-preview" hidden>
<p class="stats-preview__eyebrow">Vista previa historica</p>
<div class="stats-preview__items" id="stats-preview-items"></div>
</div>
<div class="servers-grid" id="servers-list">
<article class="server-card">
<div class="server-card__top">
<div class="server-card__identity">
<p class="server-card__eyebrow">Servidor de referencia</p>
<h3>HLL ESP Tactical Rotation</h3>
</div> </div>
<span class="server-state server-state--online">Online</span> <div class="server-card__load" aria-hidden="true">
</div> <span style="width: 74%"></span>
<div class="server-card__load" aria-hidden="true">
<span style="width: 74%"></span>
</div>
<dl class="server-card__stats">
<div class="server-stat server-stat--players">
<dt>Jugadores</dt>
<dd>74 / 100</dd>
</div> </div>
<div class="server-stat"> <dl class="server-card__stats">
<dt>Mapa</dt> <div class="server-stat server-stat--players">
<dd>Sainte-Marie-du-Mont</dd> <dt>Jugadores</dt>
<dd>74 / 100</dd>
</div>
<div class="server-stat">
<dt>Mapa</dt>
<dd>Sainte-Marie-du-Mont</dd>
</div>
<div class="server-stat">
<dt>Region</dt>
<dd>EU</dd>
</div>
</dl>
</article>
<article class="server-card">
<div class="server-card__top">
<div class="server-card__identity">
<p class="server-card__eyebrow">Referencia actual</p>
<h3>HLL LATAM Night Offensive</h3>
</div>
<div class="server-card__status-column">
<span class="server-state server-state--online">Online</span>
<p class="server-card__population">51 / 100</p>
</div>
</div> </div>
<div class="server-stat"> <div class="server-card__load" aria-hidden="true">
<dt>Region</dt> <span style="width: 51%"></span>
<dd>EU</dd>
</div> </div>
</dl> <dl class="server-card__stats">
</article> <div class="server-stat server-stat--players">
<article class="server-card"> <dt>Jugadores</dt>
<div class="server-card__top"> <dd>51 / 100</dd>
<div class="server-card__identity"> </div>
<p class="server-card__eyebrow">Servidor de referencia</p> <div class="server-stat">
<h3>HLL LATAM Night Offensive</h3> <dt>Mapa</dt>
</div> <dd>Carentan</dd>
<span class="server-state server-state--online">Online</span> </div>
</div> <div class="server-stat">
<div class="server-card__load" aria-hidden="true"> <dt>Region</dt>
<span style="width: 51%"></span> <dd>LATAM</dd>
</div> </div>
<dl class="server-card__stats"> </dl>
<div class="server-stat server-stat--players"> </article>
<dt>Jugadores</dt> </div>
<dd>51 / 100</dd>
</div>
<div class="server-stat">
<dt>Mapa</dt>
<dd>Carentan</dd>
</div>
<div class="server-stat">
<dt>Region</dt>
<dd>LATAM</dd>
</div>
</dl>
</article>
</div> </div>
</section> </section>
</main> </main>