style(overlays): redesign workshop items section

Drops the input-mode radio; the single textarea now accepts any mix of
items and collection URLs (backend autodetect landed in 5c56f18).
Refresh button moves below the items table into a .table-actions row
that also shows an item count + total size summary. Adds .workshop-input
mono font rule and a _humanize_bytes helper alongside the overlay_detail
view.

Plan deviation: PageAssertions has no not_to_contain_text method, so
the e2e test scopes those checks to a body locator instead. Caught in
review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-19 00:35:38 +02:00
parent 34b65fcbbe
commit fa394c1f7a
No known key found for this signature in database
5 changed files with 154 additions and 11 deletions

View file

@ -480,6 +480,17 @@ def _build_overlay_build_status_context(db, overlay) -> dict:
}
def _humanize_bytes(n: int) -> str:
if n < 1024:
return f"{n} B"
size = float(n)
for unit in ("KB", "MB", "GB"):
size /= 1024
if size < 1024 or unit == "GB":
return f"{size:.1f} {unit}"
return f"{size:.1f} GB"
@bp.get("/overlays/<int:overlay_id>")
@require_login
def overlay_detail(overlay_id: int):
@ -515,11 +526,16 @@ def overlay_detail(overlay_id: int):
file_tree_root_entries, file_tree_truncated_count = _root_file_tree(overlay)
total_bytes = sum((wi.file_size or 0) for wi in workshop_items)
workshop_items_total_size = _humanize_bytes(total_bytes) if total_bytes else ""
return render_template(
"overlay_detail.html",
overlay=overlay,
using_blueprints=using_blueprints,
workshop_items=workshop_items,
workshop_items_count=len(workshop_items),
workshop_items_total_size=workshop_items_total_size,
file_tree_root_entries=file_tree_root_entries,
file_tree_truncated=file_tree_truncated_count > 0
if file_tree_root_entries is not None

View file

@ -1333,3 +1333,8 @@ div.modal.modal-wide {
align-items: center;
margin-top: var(--space-m);
}
.workshop-input {
font-family: var(--font-mono);
font-size: 0.875rem;
}

View file

@ -45,17 +45,15 @@
{% if can_edit and not latest_build_is_running %}
<form method="post" action="/overlays/{{ overlay.id }}/items" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<fieldset class="workshop-input-mode">
<legend>Input mode</legend>
<label><input type="radio" name="input_mode" value="items" checked> Items (paste IDs or URLs; one or many)</label>
<label><input type="radio" name="input_mode" value="collection"> Collection (one ID or URL)</label>
</fieldset>
<label>Workshop input <textarea name="input" rows="3" placeholder="123456789&#10;https://steamcommunity.com/sharedfiles/filedetails/?id=987654321"></textarea></label>
<div class="field">
<label class="field-label" for="workshop-input">Add items</label>
<p class="field-hint">Paste Steam Workshop IDs, item URLs, or collection URLs — one per line. Collections expand automatically.</p>
<textarea id="workshop-input" name="input" rows="3" class="workshop-input"
placeholder="3726529483&#10;https://steamcommunity.com/sharedfiles/filedetails/?id=3724125665"></textarea>
</div>
<div class="form-actions-inline" style="justify-content: flex-end">
<button type="submit">Add</button>
</form>
<form method="post" action="/overlays/{{ overlay.id }}/refresh" class="stack workshop-refresh-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit">Refresh from Steam</button>
</div>
</form>
{% endif %}
@ -63,6 +61,16 @@
{% include "_overlay_item_table.html" with context %}
</div>
{% if can_edit and not latest_build_is_running %}
<div class="table-actions">
<span class="field-hint">{% if workshop_items_count %}{{ workshop_items_count }} item{{ "s" if workshop_items_count != 1 else "" }}{% if workshop_items_total_size %} · {{ workshop_items_total_size }} total{% endif %}{% else %}0 items{% endif %}</span>
<form method="post" action="/overlays/{{ overlay.id }}/refresh" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button type="submit" {% if not workshop_items_count %}disabled{% endif %}>↻ Refresh from Steam</button>
</form>
</div>
{% endif %}
{% include "_overlay_build_status.html" %}
{% endif %}

View file

@ -170,6 +170,45 @@ def files_overlay_server(tmp_path, monkeypatch):
shutdown()
@pytest.fixture(scope="function")
def workshop_overlay_server(tmp_path, monkeypatch):
"""live_server + a workshop-type Overlay owned by alice. The overlay
starts with zero items tests that need items should seed them via
direct DB writes or via UI actions inside the test."""
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
app = _boot_app(tmp_path, monkeypatch)
with session_scope() as session:
user = User(
username="alice",
password_digest=hash_password("secret"),
admin=False,
)
session.add(user)
session.flush()
overlay = Overlay(
name="my-maps",
path="_pending",
type="workshop",
user_id=user.id,
)
session.add(overlay)
session.flush()
overlay.path = str(overlay.id)
user_id = user.id
overlay_id = overlay.id
base_url, shutdown = _serve(app)
try:
yield {
"base_url": base_url,
"user_id": user_id,
"overlay_id": overlay_id,
}
finally:
shutdown()
@pytest.fixture(scope="function")
def server_with_files(tmp_path, monkeypatch):
"""live_server + a Server owned by alice with a populated runtime

View file

@ -0,0 +1,75 @@
"""E2E test for the redesigned workshop items section."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from playwright.sync_api import Page, expect
from l4d2web.services import steam_workshop
from .conftest import login
pytestmark = pytest.mark.e2e
def _meta(steam_id: str) -> steam_workshop.WorkshopMetadata:
return steam_workshop.WorkshopMetadata(
steam_id=steam_id,
title=f"Item {steam_id}",
filename=f"{steam_id}.vpk",
file_url=f"https://example.com/{steam_id}.vpk",
file_size=1024,
time_updated=1700000000,
preview_url="",
consumer_app_id=550,
result=1,
)
def test_workshop_section_renders_without_input_mode_radio(page: Page, workshop_overlay_server) -> None:
base_url = workshop_overlay_server["base_url"]
overlay_id = workshop_overlay_server["overlay_id"]
login(page, base_url)
page.goto(f"{base_url}/overlays/{overlay_id}")
# Legacy input-mode fieldset is gone.
expect(page.locator("fieldset.workshop-input-mode")).to_have_count(0)
body = page.locator("body")
expect(body).not_to_contain_text("Input mode")
expect(body).not_to_contain_text("Items (paste IDs or URLs; one or many)")
expect(body).not_to_contain_text("Collection (one ID or URL)")
# Single textarea + Add button.
expect(page.locator('textarea[name="input"]')).to_be_visible()
expect(page.locator('button:has-text("Add")')).to_be_visible()
# Refresh button is below the items table.
table = page.locator("#overlay-item-table")
refresh = page.locator('button:has-text("Refresh from Steam")')
expect(table).to_be_visible()
expect(refresh).to_be_visible()
table_y = table.bounding_box()["y"]
refresh_y = refresh.bounding_box()["y"]
assert table_y < refresh_y, "Refresh button should sit below the items table"
def test_workshop_autodetect_expands_collection_url(page: Page, workshop_overlay_server) -> None:
"""Pasting a collection URL into the textarea (no mode toggle) expands to
children server-side. Verified by patching the Steam helpers."""
base_url = workshop_overlay_server["base_url"]
overlay_id = workshop_overlay_server["overlay_id"]
login(page, base_url)
with patch.object(
steam_workshop, "expand_collections", return_value=["1001", "1002"]
), patch.object(
steam_workshop, "fetch_metadata_batch", return_value=[_meta("1001"), _meta("1002")]
):
page.goto(f"{base_url}/overlays/{overlay_id}")
page.locator('textarea[name="input"]').fill(
"https://steamcommunity.com/sharedfiles/filedetails/?id=555"
)
page.locator('button:has-text("Add")').click()
# Handler redirects to /jobs/<n>.
page.wait_for_url("**/jobs/*", timeout=5000)