feat(live-state): ?view=recent-modal branch + single-column modal list

Adds the _recent_players_modal_body.html partial for the full recent-players
list (no 10-item cap), the route branch in live_state_fragment that renders it
when ?view=recent-modal is requested, and the .recent-modal-list CSS rule that
forces single-column layout inside the modal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-17 21:25:36 +02:00
parent 96bbd0c136
commit 6de5f90626
No known key found for this signature in database
4 changed files with 73 additions and 0 deletions

View file

@ -266,6 +266,12 @@ def live_state_fragment(server_id: int) -> Response:
recent_total = len(recent_rows)
recent_overview = recent_rows[:10]
if request.args.get("view") == "recent-modal":
return render_template(
"_recent_players_modal_body.html",
recent_players=recent_rows,
)
return render_template(
"_live_state.html",
server=server,

View file

@ -1120,3 +1120,14 @@ div.modal.modal-wide {
.console-input-form.htmx-request .console-spinner {
display: inline;
}
.recent-modal-list {
/* Force a single column inside the modal regardless of the 5-col
default on .player-grid.recent. !important is acceptable here:
the only way to override the more-specific class selector
chain is via specificity or !important; modal-only override
is local enough that long-term maintainability is fine. */
grid-template-columns: 1fr !important;
max-height: 60vh;
overflow: auto;
}

View file

@ -0,0 +1,21 @@
{# Full single-column list of recent players for the
#recent-players-modal. Rendered via /servers/<id>/live-state?view=recent-modal.
Reuses the same chip markup as the inline grid in _live_state.html. #}
<ul class="player-grid recent recent-modal-list">
{% for row in recent_players %}
{% set display_name = row.persona_name or row.name_at_join %}
<li class="player-card recent-chip">
<a class="player-link"
href="https://steamcommunity.com/profiles/{{ row.steam_id_64 }}"
target="_blank" rel="noopener noreferrer">
{% if row.avatar_url %}
<img class="avatar" src="{{ row.avatar_url }}" alt="">
{% else %}
<span class="avatar placeholder" aria-hidden="true"></span>
{% endif %}
<span class="name" title="{{ display_name }}">{{ display_name }}</span>
</a>
<span class="meta">· {{ row.last_seen | timeago }}</span>
</li>
{% endfor %}
</ul>

View file

@ -136,3 +136,38 @@ def test_live_state_recent_header_not_clickable_when_le_10(owner_client_with_ser
assert "4 Recent" in html
assert 'data-inline-modal-open="recent-players-modal"' not in html
def test_live_state_recent_modal_view_returns_single_column_chip_list(owner_client_with_server) -> None:
"""?view=recent-modal renders all recent players (not just the
10-item overview) in a single-column scrollable list using the
same chip markup."""
from datetime import timedelta
from l4d2web.models import ServerPlayerSession
client, server_id = owner_client_with_server
now = datetime.now(UTC)
total = 13
with session_scope() as db:
for i in range(total):
db.add(ServerPlayerSession(
server_id=server_id,
steam_id_64=str(76561190000000000 + i),
joined_at=now - timedelta(hours=i + 2),
left_at=now - timedelta(hours=i + 1),
name_at_join=f"Player{i}",
min_ping=10,
max_ping=50,
))
resp = client.get(f"/servers/{server_id}/live-state?view=recent-modal")
assert resp.status_code == 200
body = resp.get_data(as_text=True)
# All 13 chips present (no slice in the modal view).
assert body.count('class="player-card recent-chip"') == 13
# Has the modal-list wrapper class.
assert 'class="player-grid recent recent-modal-list"' in body
# No "Current" or "N Recent" header — modal view is just the list.
assert "Recent" not in body or "Recent</button>" not in body # accept either: the test is about the list rendering, not headers