From 188365f412368403bde25884f664731bedfaa981 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sat, 4 Apr 2026 13:49:48 -0400 Subject: [PATCH] game kit gear menu + login form UX polish; left-side position indicator flip game kit: new Applet model rows (context=game-kit) for Trinkets, Tokens, Card Decks, Dice Sets via applets migration 0008; _game_kit_context() helper in gameboard.views; toggle_game_kit_sections view + URL; new _game_kit_sections.html (HTMX-swappable, visibility-conditional) + _game_kit_applet_menu.html partials; game_kit.html wired to gear btn + menu; Dice Sets now renders _forthcoming.html partial; 16 new green ITs in GameKitViewTest + ToggleGameKitSectionsViewTest login form: .input-group now position:fixed + vertically centred (top:50%) across all breakpoints as default; landscape block reduced to left/right sidebar offsets only; form-control width 24rem, text-align:center; alert block moved below h2 in base.html; alert margin 0.75rem all sides; home.html header switches between Howdy Stranger (anon) and Dashboard (authed) room.html position indicators: slots 3/4/5 (AC/SC/EC) column order flipped via SCSS data-slot selectors so .fa-chair sits table-side and label+status icon sit outward Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/0008_game_kit_applets.py | 25 +++++ .../gameboard/tests/integrated/test_views.py | 104 ++++++++++++++++++ src/apps/gameboard/urls.py | 1 + src/apps/gameboard/views.py | 40 +++++-- src/static_src/scss/_base.scss | 20 ++-- src/static_src/scss/_room.scss | 7 ++ src/templates/apps/dashboard/home.html | 8 +- .../_partials/_game_kit_applet_menu.html | 24 ++++ .../_partials/_game_kit_sections.html | 81 ++++++++++++++ src/templates/apps/gameboard/game_kit.html | 77 +------------ src/templates/core/base.html | 16 +-- 11 files changed, 301 insertions(+), 102 deletions(-) create mode 100644 src/apps/applets/migrations/0008_game_kit_applets.py create mode 100644 src/templates/apps/gameboard/_partials/_game_kit_applet_menu.html create mode 100644 src/templates/apps/gameboard/_partials/_game_kit_sections.html diff --git a/src/apps/applets/migrations/0008_game_kit_applets.py b/src/apps/applets/migrations/0008_game_kit_applets.py new file mode 100644 index 0000000..ff4e320 --- /dev/null +++ b/src/apps/applets/migrations/0008_game_kit_applets.py @@ -0,0 +1,25 @@ +from django.db import migrations + + +def seed_game_kit_applets(apps, schema_editor): + Applet = apps.get_model('applets', 'Applet') + for slug, name in [ + ('gk-trinkets', 'Trinkets'), + ('gk-tokens', 'Tokens'), + ('gk-decks', 'Card Decks'), + ('gk-dice', 'Dice Sets'), + ]: + Applet.objects.get_or_create( + slug=slug, + defaults={'name': name, 'grid_cols': 3, 'grid_rows': 3, 'context': 'game-kit'}, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ('applets', '0007_fix_billboard_applets'), + ] + + operations = [ + migrations.RunPython(seed_game_kit_applets, migrations.RunPython.noop) + ] diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 758f551..1bd40b0 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -106,6 +106,110 @@ class ToggleGameAppletsViewTest(TestCase): self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists()) +class GameKitViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="gamer@test.io") + self.client.force_login(self.user) + Applet.objects.get_or_create(slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"}) + Applet.objects.get_or_create(slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"}) + Applet.objects.get_or_create(slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"}) + Applet.objects.get_or_create(slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"}) + response = self.client.get("/gameboard/game-kit/") + self.parsed = lxml.html.fromstring(response.content) + + def test_game_kit_requires_login(self): + self.client.logout() + response = self.client.get("/gameboard/game-kit/") + self.assertRedirects(response, "/?next=/gameboard/game-kit/", fetch_redirect_response=False) + + def test_game_kit_shows_gear_btn(self): + [_] = self.parsed.cssselect(".gear-btn") + + def test_game_kit_shows_applet_menu(self): + [_] = self.parsed.cssselect("#id_game_kit_menu") + + def test_game_kit_applet_menu_has_trinkets_checkbox(self): + [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-trinkets']") + self.assertEqual(inp.get("type"), "checkbox") + + def test_game_kit_applet_menu_has_tokens_checkbox(self): + [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-tokens']") + self.assertEqual(inp.get("type"), "checkbox") + + def test_game_kit_applet_menu_has_decks_checkbox(self): + [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-decks']") + self.assertEqual(inp.get("type"), "checkbox") + + def test_game_kit_applet_menu_has_dice_checkbox(self): + [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-dice']") + self.assertEqual(inp.get("type"), "checkbox") + + def test_game_kit_sections_container_present(self): + [_] = self.parsed.cssselect("#id_gk_sections_container") + + def test_all_sections_visible_by_default(self): + sections = self.parsed.cssselect("#id_gk_sections_container section") + self.assertEqual(len(sections), 4) + + +class ToggleGameKitSectionsViewTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="gamer@test.io") + self.client.force_login(self.user) + self.trinkets, _ = Applet.objects.get_or_create( + slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"} + ) + self.tokens, _ = Applet.objects.get_or_create( + slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"} + ) + self.decks, _ = Applet.objects.get_or_create( + slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"} + ) + self.dice, _ = Applet.objects.get_or_create( + slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"} + ) + self.url = reverse("toggle_game_kit_sections") + + def test_unauthenticated_user_is_redirected(self): + self.client.logout() + response = self.client.post(self.url) + self.assertRedirects(response, f"/?next={self.url}", fetch_redirect_response=False) + + def test_unchecked_section_gets_user_applet_with_visible_false(self): + self.client.post(self.url, {"applets": ["gk-trinkets"]}) + ua = UserApplet.objects.get(user=self.user, applet=self.tokens) + self.assertFalse(ua.visible) + + def test_redirects_on_normal_post(self): + response = self.client.post(self.url, {"applets": ["gk-trinkets", "gk-tokens"]}) + self.assertRedirects(response, reverse("game_kit"), fetch_redirect_response=False) + + def test_returns_200_on_htmx_post(self): + response = self.client.post( + self.url, + {"applets": ["gk-trinkets", "gk-tokens"]}, + HTTP_HX_REQUEST="true", + ) + self.assertEqual(response.status_code, 200) + + def test_does_not_affect_gameboard_applets(self): + gb_applet, _ = Applet.objects.get_or_create( + slug="new-game", defaults={"name": "New Game", "context": "gameboard"} + ) + self.client.post(self.url, {"applets": ["gk-trinkets"]}) + self.assertFalse(UserApplet.objects.filter(user=self.user, applet=gb_applet).exists()) + + def test_hidden_section_absent_from_htmx_response(self): + response = self.client.post( + self.url, + {"applets": ["gk-trinkets"]}, + HTTP_HX_REQUEST="true", + ) + parsed = lxml.html.fromstring(response.content) + sections = parsed.cssselect("section") + self.assertEqual(len(sections), 1) + + class EquipTrinketViewTest(TestCase): def setUp(self): self.user = User.objects.create(email="gamer@test.io") diff --git a/src/apps/gameboard/urls.py b/src/apps/gameboard/urls.py index ea42837..77c85b1 100644 --- a/src/apps/gameboard/urls.py +++ b/src/apps/gameboard/urls.py @@ -9,6 +9,7 @@ urlpatterns = [ path('equip-trinket//', views.equip_trinket, name='equip_trinket'), path('equip-deck//', views.equip_deck, name='equip_deck'), path('game-kit/', views.game_kit, name='game_kit'), + path('game-kit/toggle-sections', views.toggle_game_kit_sections, name='toggle_game_kit_sections'), path('game-kit/deck//', views.tarot_fan, name='tarot_fan'), ] diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 323d8a5..ab3d877 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -102,26 +102,48 @@ def equip_deck(request, deck_id): return HttpResponse(status=405) -@login_required(login_url="/") -def game_kit(request): - coin = request.user.tokens.filter(token_type=Token.COIN).first() - pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None - carte = request.user.tokens.filter(token_type=Token.CARTE).first() - free_tokens = list(request.user.tokens.filter( +def _game_kit_context(user): + coin = user.tokens.filter(token_type=Token.COIN).first() + pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None + carte = user.tokens.filter(token_type=Token.CARTE).first() + free_tokens = list(user.tokens.filter( token_type=Token.FREE, expires_at__gt=timezone.now() ).order_by("expires_at")) - tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE)) - return render(request, "apps/gameboard/game_kit.html", { + tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE)) + return { "coin": coin, "pass_token": pass_token, "carte": carte, "free_tokens": free_tokens, "tithe_tokens": tithe_tokens, - "unlocked_decks": list(request.user.unlocked_decks.all()), + "unlocked_decks": list(user.unlocked_decks.all()), + "applets": applet_context(user, "game-kit"), + } + + +@login_required(login_url="/") +def game_kit(request): + return render(request, "apps/gameboard/game_kit.html", { + **_game_kit_context(request.user), "page_class": "page-gameboard", }) +@login_required(login_url="/") +def toggle_game_kit_sections(request): + checked = request.POST.getlist("applets") + for applet in Applet.objects.filter(context="game-kit"): + UserApplet.objects.update_or_create( + user=request.user, + applet=applet, + defaults={"visible": applet.slug in checked}, + ) + if request.headers.get("HX-Request"): + return render(request, "apps/gameboard/_partials/_game_kit_sections.html", + _game_kit_context(request.user)) + return redirect("game_kit") + + @login_required(login_url="/") def tarot_fan(request, deck_id): from apps.epic.models import TarotCard diff --git a/src/static_src/scss/_base.scss b/src/static_src/scss/_base.scss index 3351818..c49584d 100644 --- a/src/static_src/scss/_base.scss +++ b/src/static_src/scss/_base.scss @@ -68,13 +68,20 @@ body { } .input-group { + position: fixed; + left: 0; + right: 0; + top: 50%; + transform: translateY(-50%); display: flex; flex-direction: column; align-items: center; gap: 0.5rem; + z-index: 50; .form-control { - width: auto; + width: 24rem; + text-align: center; } } @@ -117,7 +124,7 @@ body { .alert { padding: 0.75rem 1rem; - margin: 0.75rem 0; + margin: 0.75rem; border-radius: 0.5rem; border: 0.1rem solid rgba(var(--priYl), 0.5); color: rgba(var(--priYl), 1); @@ -243,17 +250,10 @@ body { // margin-left: 0.75rem; } - // Login form: pull out of narrow sidebar, centre in the viewport content area + // Login form: offset from fixed sidebars in landscape .input-group { - display: flex; - position: fixed; left: $sidebar-w; right: $sidebar-w; - top: 50%; - transform: translateY(-50%); - flex-direction: column; - align-items: center; - z-index: 50; .navbar-text { writing-mode: horizontal-tb; diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 95e310a..8645777 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -526,6 +526,13 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut &.fa-circle-check { color: rgba(var(--priGn), 1); } } + // Left-side positions: flip column order so chair is closest to the table + &[data-slot="3"], &[data-slot="4"], &[data-slot="5"] { + .fa-chair { grid-column: 2; } + .seat-role-label { grid-column: 1; } + .position-status-icon { grid-column: 1; } + } + &.active .fa-chair { color: rgba(var(--terUser), 1); filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1)); diff --git a/src/templates/apps/dashboard/home.html b/src/templates/apps/dashboard/home.html index f711792..f22d2cf 100644 --- a/src/templates/apps/dashboard/home.html +++ b/src/templates/apps/dashboard/home.html @@ -2,7 +2,13 @@ {% load lyric_extras %} {% block title_text %}Dashboard{% endblock title_text %} -{% block header_text %}Dashboard{% endblock header_text %} +{% block header_text %} + {% if user.is_authenticated %} + Dashboard + {% else %} + Howdy stranger + {% endif %} +{% endblock header_text %} {% block scripts %} {% include "apps/dashboard/_partials/_scripts.html" %} diff --git a/src/templates/apps/gameboard/_partials/_game_kit_applet_menu.html b/src/templates/apps/gameboard/_partials/_game_kit_applet_menu.html new file mode 100644 index 0000000..066b1db --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_game_kit_applet_menu.html @@ -0,0 +1,24 @@ + diff --git a/src/templates/apps/gameboard/_partials/_game_kit_sections.html b/src/templates/apps/gameboard/_partials/_game_kit_sections.html new file mode 100644 index 0000000..fadf6af --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_game_kit_sections.html @@ -0,0 +1,81 @@ +
+ {% for entry in applets %} + {% if entry.applet.slug == 'gk-trinkets' and entry.visible %} +
+

Trinkets

+
+ {% if pass_token %} +
+ + {{ pass_token.tooltip_name }} +
+ {% endif %} + {% if carte %} +
+ + {{ carte.tooltip_name }} +
+ {% endif %} + {% if coin %} +
+ + {{ coin.tooltip_name }} +
+ {% endif %} + {% if not pass_token and not carte and not coin %} +

No trinkets yet.

+ {% endif %} +
+
+ {% endif %} + + {% if entry.applet.slug == 'gk-tokens' and entry.visible %} +
+

Tokens

+
+ {% for token in free_tokens %} +
+ + {{ token.tooltip_name }} +
+ {% endfor %} + {% for token in tithe_tokens %} +
+ + {{ token.tooltip_name }} +
+ {% endfor %} + {% if not free_tokens and not tithe_tokens %} +

No tokens yet.

+ {% endif %} +
+
+ {% endif %} + + {% if entry.applet.slug == 'gk-decks' and entry.visible %} +
+

Card Decks

+
+ {% for deck in unlocked_decks %} +
+ + {{ deck.name }} + {{ deck.card_count }} cards +
+ {% empty %} +

No decks unlocked.

+ {% endfor %} +
+
+ {% endif %} + + {% if entry.applet.slug == 'gk-dice' and entry.visible %} +
+

Dice Sets

+
+ {% include "core/_partials/_forthcoming.html" %} +
+
+ {% endif %} + {% endfor %} +
diff --git a/src/templates/apps/gameboard/game_kit.html b/src/templates/apps/gameboard/game_kit.html index cd6a5f2..0b2958a 100644 --- a/src/templates/apps/gameboard/game_kit.html +++ b/src/templates/apps/gameboard/game_kit.html @@ -6,81 +6,10 @@ {% block content %}
+ {% include "apps/applets/_partials/_gear.html" with menu_id="id_game_kit_menu" %} + {% include "apps/gameboard/_partials/_game_kit_applet_menu.html" %}
- -
-

Trinkets

-
- {% if pass_token %} -
- - {{ pass_token.tooltip_name }} -
- {% endif %} - {% if carte %} -
- - {{ carte.tooltip_name }} -
- {% endif %} - {% if coin %} -
- - {{ coin.tooltip_name }} -
- {% endif %} - {% if not pass_token and not carte and not coin %} -

No trinkets yet.

- {% endif %} -
-
- -
-

Tokens

-
- {% for token in free_tokens %} -
- - {{ token.tooltip_name }} -
- {% endfor %} - {% for token in tithe_tokens %} -
- - {{ token.tooltip_name }} -
- {% endfor %} - {% if not free_tokens and not tithe_tokens %} -

No tokens yet.

- {% endif %} -
-
- -
-

Card Decks

-
- {% for deck in unlocked_decks %} -
- - {{ deck.name }} - {{ deck.card_count }} cards -
- {% empty %} -

No decks unlocked.

- {% endfor %} -
-
- -
-

Dice Sets

-
-
- - Coming soon -
-
-
- + {% include "apps/gameboard/_partials/_game_kit_sections.html" %}
diff --git a/src/templates/core/base.html b/src/templates/core/base.html index 85d2bd8..2625ea1 100644 --- a/src/templates/core/base.html +++ b/src/templates/core/base.html @@ -21,6 +21,14 @@
{% include "core/_partials/_navbar.html" %} +
+
+

{% block header_text %}{% endblock header_text %}

+ {% block extra_header %} + {% endblock extra_header %} +
+
+ {% if messages %}
@@ -35,14 +43,6 @@
{% endif %} -
-
-

{% block header_text %}{% endblock header_text %}

- {% block extra_header %} - {% endblock extra_header %} -
-
- {% block content %} {% endblock content %}