diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py
index 48a95ae..0b42c7c 100644
--- a/common/components/custom_elements.py
+++ b/common/components/custom_elements.py
@@ -112,3 +112,12 @@ class SessionDeviceSelectorProps(TypedDict):
register_element(
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
)
+
+
+class PlayEventRowProps(TypedDict):
+ game_id: int
+ csrf: str
+ api_create_url: str
+
+
+register_element("play-event-row", "PlayEventRow", PlayEventRowProps)
diff --git a/e2e/test_custom_elements_e2e.py b/e2e/test_custom_elements_e2e.py
index c5c8782..9488d40 100644
--- a/e2e/test_custom_elements_e2e.py
+++ b/e2e/test_custom_elements_e2e.py
@@ -61,3 +61,24 @@ def test_session_device_selector_patches(authenticated_page: Page, live_server):
host.locator(f'[data-option][data-value="{deck.id}"]').click()
session.refresh_from_db()
assert session.device_id == deck.id
+
+
+@pytest.mark.django_db
+def test_play_event_row_increments(authenticated_page: Page, live_server):
+ from games.models import Game, Platform
+
+ platform = Platform.objects.create(name="PC", icon="pc")
+ game = Game.objects.create(name="Test Game", platform=platform)
+
+ page = authenticated_page
+ page.goto(f"{live_server.url}{reverse('games:view_game', args=[game.id])}")
+
+ host = page.locator("play-event-row").first
+ expect(host).to_be_attached()
+ host.locator("[data-toggle]").click()
+ with page.expect_response(
+ lambda r: "playevent" in r.url.lower() and r.request.method == "POST"
+ ):
+ host.locator("[data-add-play]").click()
+ expect(host.locator("[data-count]")).to_have_text("1")
+ assert game.playevents.count() == 1
diff --git a/games/views/game.py b/games/views/game.py
index 1ef8713..6e7756a 100644
--- a/games/views/game.py
+++ b/games/views/game.py
@@ -39,7 +39,6 @@ from common.components import (
paginated_table_content,
)
from common.components.primitives import Li, P, Span, Strong
-from common.icons import get_icon
from common.layout import render_page
from common.time import (
dateformat,
@@ -340,69 +339,73 @@ _STAT_SVGS = {
"playrange": '',
}
-_PLAYED_ROW_TEMPLATE = """
"""
+_PLAYED_BTN = (
+ "px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
+ "hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
+ "dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
+)
+_PLAYED_MENU = (
+ "absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium "
+ "bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border "
+ "border-gray-200 dark:border-gray-700"
+)
def _played_row(game: Game, request: HttpRequest) -> Node:
- """The 'Played N times' control with its Alpine.js dropdown."""
- replacements = {
- "@@PLAYED_COUNT@@": str(game.playevents.count()),
- "@@ADD_PE@@": reverse("games:add_playevent"),
- "@@ARROWDOWN@@": get_icon("arrowdown"),
- "@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]),
- "@@API_CREATE@@": reverse("api-1.0.0:create_playevent"),
- "@@CSRF@@": get_token(request),
- "@@GAME_ID@@": str(game.id),
- }
- html = _PLAYED_ROW_TEMPLATE
- for token, value in replacements.items():
- html = html.replace(token, value)
- return Safe(html)
+ """'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
+ from common.components import Element, custom_element
+ from common.components.custom_elements import PlayEventRowProps
+
+ played = game.playevents.count()
+
+ count_button = A(href=reverse("games:add_playevent"))[
+ Element(
+ "button",
+ [("type", "button"), ("class", _PLAYED_BTN + " rounded-s-lg")],
+ [Span(data_count="")[str(played)], " times"],
+ )
+ ]
+ menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
+ Ul()[
+ Li(attributes=[("class", "px-4 py-2")])[
+ A(href=reverse("games:add_playevent_for_game", args=[game.id]))[
+ "Add playthrough..."
+ ]
+ ],
+ Li(attributes=[("class", "px-4 py-2 cursor-pointer")])[
+ Element(
+ "button",
+ [("type", "button"), ("data-add-play", "")],
+ children=["Played times +1"],
+ )
+ ],
+ ]
+ ]
+ toggle = Element(
+ "button",
+ [
+ ("type", "button"),
+ ("data-toggle", ""),
+ ("class", _PLAYED_BTN + " rounded-e-lg relative"),
+ ],
+ [Icon("arrowdown"), menu],
+ )
+ group = Div(class_="inline-flex rounded-md shadow-2xs relative")[
+ count_button, toggle
+ ]
+ return custom_element(
+ "play-event-row",
+ PlayEventRowProps(
+ game_id=game.id,
+ csrf=get_token(request),
+ api_create_url=reverse("api-1.0.0:create_playevent"),
+ ),
+ children=[
+ Div(class_="flex gap-2 items-center")[
+ Span(class_="uppercase")["Played"], group
+ ]
+ ],
+ )
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
diff --git a/tests/test_rendered_pages.py b/tests/test_rendered_pages.py
index 8f7b8ab..73bb7ce 100644
--- a/tests/test_rendered_pages.py
+++ b/tests/test_rendered_pages.py
@@ -168,7 +168,7 @@ class RenderedPagesTest(TestCase):
"Platform",
'id="history-container"',
"status-changed from:body",
- "createPlayEvent", # the played-row Alpine dropdown script
+ ""))
+ def test_view_game_uses_play_event_row_element(self):
+ game = Game.objects.create(name="Played Game", platform=self.platform)
+ html = self.get("games:view_game", game.id).content.decode()
+ self.assertIn("("[data-toggle]");
+ const menu = this.querySelector("[data-menu]");
+ const count = this.querySelector("[data-count]");
+ const addPlay = this.querySelector("[data-add-play]");
+ if (!toggle || !menu) return;
+
+ const close = () => {
+ menu.hidden = true;
+ };
+ toggle.addEventListener("click", (event) => {
+ event.stopPropagation();
+ menu.hidden = !menu.hidden;
+ });
+ document.addEventListener("click", (event) => {
+ if (!this.contains(event.target as Node)) close();
+ });
+
+ addPlay?.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (count) count.textContent = String(Number(count.textContent) + 1);
+ close();
+ window
+ .fetchWithHtmxTriggers(props.apiCreateUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-CSRFToken": props.csrf },
+ body: JSON.stringify({ game_id: props.gameId }),
+ })
+ .catch(() => {
+ if (count) count.textContent = String(Number(count.textContent) - 1);
+ console.error("Failed to record play");
+ });
+ });
+ }
+}
+
+customElements.define("play-event-row", PlayEventRowElement);