Compare commits

..

2 Commits

22 changed files with 314 additions and 110 deletions

View File

@@ -6,7 +6,9 @@ const initGearMenus = () => {
e.stopPropagation(); e.stopPropagation();
const menu = document.getElementById(menuId); const menu = document.getElementById(menuId);
if (!menu) return; if (!menu) return;
menu.style.display = menu.style.display === 'none' ? 'block' : 'none'; const opening = menu.style.display === 'none' || menu.style.display === '';
menu.style.display = opening ? 'block' : 'none';
gear.classList.toggle('active', opening);
}); });
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
@@ -14,6 +16,7 @@ const initGearMenus = () => {
if (!menu || menu.style.display === 'none') return; if (!menu || menu.style.display === 'none') return;
if (e.target.closest('.applet-menu-cancel') || !menu.contains(e.target)) { if (e.target.closest('.applet-menu-cancel') || !menu.contains(e.target)) {
menu.style.display = 'none'; menu.style.display = 'none';
gear.classList.remove('active');
} }
}); });
}) })
@@ -33,5 +36,6 @@ document.body.addEventListener('htmx:afterSwap', (e) => {
document.querySelectorAll('.gear-btn').forEach(gear => { document.querySelectorAll('.gear-btn').forEach(gear => {
const menu = document.getElementById(gear.dataset.menuTarget); const menu = document.getElementById(gear.dataset.menuTarget);
if (menu) menu.style.display = 'none'; if (menu) menu.style.display = 'none';
gear.classList.remove('active');
}); });
}); });

View File

@@ -9,8 +9,10 @@
}); });
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
if (dialog.open) { if (btn.classList.contains('active')) {
dialog.close(); dialog.removeAttribute('open');
btn.classList.remove('active');
clearSelection();
return; return;
} }
fetch(btn.dataset.kitUrl, { fetch(btn.dataset.kitUrl, {
@@ -61,9 +63,9 @@
}); });
function attachCardListeners() { function attachCardListeners() {
dialog.querySelectorAll('.kit-card').forEach(function (card) { dialog.querySelectorAll('.token[data-token-id]').forEach(function (card) {
card.addEventListener('click', function () { card.addEventListener('click', function () {
dialog.querySelectorAll('.kit-card.selected').forEach(function (c) { dialog.querySelectorAll('.token[data-token-id].selected').forEach(function (c) {
c.classList.remove('selected'); c.classList.remove('selected');
}); });
card.classList.add('selected'); card.classList.add('selected');
@@ -71,6 +73,21 @@
var slot = document.querySelector('.token-slot'); var slot = document.querySelector('.token-slot');
if (slot) slot.classList.add('ready'); if (slot) slot.classList.add('ready');
}); });
card.addEventListener('mouseenter', function () {
var tooltip = card.querySelector('.token-tooltip');
if (!tooltip) return;
var rect = card.getBoundingClientRect();
tooltip.style.position = 'fixed';
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
tooltip.style.left = rect.left + 'px';
tooltip.style.display = 'block';
});
card.addEventListener('mouseleave', function () {
var tooltip = card.querySelector('.token-tooltip');
if (tooltip) tooltip.style.display = '';
});
}); });
} }

View File

@@ -6,6 +6,7 @@ from django.contrib.auth.decorators import login_required
from django.db.models import Max, Q from django.db.models import Max, Q
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils import timezone
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from apps.applets.models import Applet, UserApplet from apps.applets.models import Applet, UserApplet
@@ -148,8 +149,14 @@ def wallet(request):
"wallet": request.user.wallet, "wallet": request.user.wallet,
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(), "pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), "free_tokens": list(request.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)), "tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
"free_count": request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).count(),
"tithe_count": request.user.tokens.filter(token_type=Token.TITHE).count(),
"applets": applet_context(request.user, "wallet"), "applets": applet_context(request.user, "wallet"),
"page_class": "page-wallet", "page_class": "page-wallet",
}) })
@@ -158,7 +165,18 @@ def wallet(request):
@login_required(login_url="/") @login_required(login_url="/")
def kit_bag(request): def kit_bag(request):
tokens = list(request.user.tokens.all()) tokens = list(request.user.tokens.all())
return render(request, "core/_partials/_kit_bag_panel.html", {"tokens": tokens}) free_tokens = sorted(
[t for t in tokens if t.token_type == Token.FREE and t.expires_at and t.expires_at > timezone.now()],
key=lambda t: t.expires_at,
)
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
return render(request, "core/_partials/_kit_bag_panel.html", {
"tokens": tokens,
"free_token": free_tokens[0] if free_tokens else None,
"free_count": len(free_tokens),
"tithe_token": tithe_tokens[0] if tithe_tokens else None,
"tithe_count": len(tithe_tokens),
})
@login_required(login_url="/") @login_required(login_url="/")
def toggle_wallet_applets(request): def toggle_wallet_applets(request):

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0004_alter_room_gate_status'),
]
operations = [
migrations.AddField(
model_name='gateslot',
name='debited_token_type',
field=models.CharField(max_length=8, null=True, blank=True),
),
migrations.AddField(
model_name='gateslot',
name='debited_token_expires_at',
field=models.DateTimeField(null=True, blank=True),
),
]

View File

@@ -65,6 +65,8 @@ class GateSlot(models.Model):
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=EMPTY) status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=EMPTY)
reserved_at = models.DateTimeField(null=True, blank=True) reserved_at = models.DateTimeField(null=True, blank=True)
filled_at = models.DateTimeField(null=True, blank=True) filled_at = models.DateTimeField(null=True, blank=True)
debited_token_type = models.CharField(max_length=8, null=True, blank=True)
debited_token_expires_at = models.DateTimeField(null=True, blank=True)
class RoomInvite(models.Model): class RoomInvite(models.Model):
@@ -111,12 +113,14 @@ def select_token(user):
def debit_token(user, slot, token): def debit_token(user, slot, token):
slot.debited_token_type = token.token_type
if token.token_type == Token.COIN: if token.token_type == Token.COIN:
token.current_room = slot.room token.current_room = slot.room
period = slot.room.renewal_period or timedelta(days=7) period = slot.room.renewal_period or timedelta(days=7)
token.next_ready_at = timezone.now() + period token.next_ready_at = timezone.now() + period
token.save() token.save()
elif token.token_type != Token.PASS: elif token.token_type != Token.PASS:
slot.debited_token_expires_at = token.expires_at
token.delete() token.delete()
slot.gamer = user slot.gamer = user
slot.status = GateSlot.FILLED slot.status = GateSlot.FILLED

View File

@@ -1,3 +1,4 @@
from datetime import timedelta
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@@ -189,7 +190,7 @@ class ConfirmTokenViewTest(TestCase):
self.assertEqual(self.slot.status, GateSlot.EMPTY) self.assertEqual(self.slot.status, GateSlot.EMPTY)
class RejectTokenViewTest(TestCase): class ReturnTokenViewTest(TestCase):
def setUp(self): def setUp(self):
self.gamer = User.objects.create(email="gamer@test.io") self.gamer = User.objects.create(email="gamer@test.io")
self.client.force_login(self.gamer) self.client.force_login(self.gamer)
@@ -201,33 +202,73 @@ class RejectTokenViewTest(TestCase):
self.slot.reserved_at = timezone.now() self.slot.reserved_at = timezone.now()
self.slot.save() self.slot.save()
def test_reject_clears_reserved_slot(self): def test_return_clears_reserved_slot(self):
self.client.post( self.client.post(
reverse("epic:reject_token", kwargs={"room_id": self.room.id}) reverse("epic:return_token", kwargs={"room_id": self.room.id})
) )
self.slot.refresh_from_db() self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.EMPTY) self.assertEqual(self.slot.status, GateSlot.EMPTY)
self.assertIsNone(self.slot.gamer) self.assertIsNone(self.slot.gamer)
self.assertIsNone(self.slot.reserved_at) self.assertIsNone(self.slot.reserved_at)
def test_reject_after_confirm_clears_filled_slot(self): def test_return_after_confirm_clears_filled_slot(self):
self.slot.status = GateSlot.FILLED self.slot.status = GateSlot.FILLED
self.slot.save() self.slot.save()
self.client.post( self.client.post(
reverse("epic:reject_token", kwargs={"room_id": self.room.id}) reverse("epic:return_token", kwargs={"room_id": self.room.id})
) )
self.slot.refresh_from_db() self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.EMPTY) self.assertEqual(self.slot.status, GateSlot.EMPTY)
self.assertIsNone(self.slot.gamer) self.assertIsNone(self.slot.gamer)
def test_reject_redirects_to_gatekeeper(self): def test_return_redirects_to_gatekeeper(self):
response = self.client.post( response = self.client.post(
reverse("epic:reject_token", kwargs={"room_id": self.room.id}) reverse("epic:return_token", kwargs={"room_id": self.room.id})
) )
self.assertRedirects( self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id]) response, reverse("epic:gatekeeper", args=[self.room.id])
) )
def test_return_restores_coin_token(self):
coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
coin.current_room = self.room
coin.next_ready_at = timezone.now() + timedelta(days=7)
coin.save()
self.slot.status = GateSlot.FILLED
self.slot.debited_token_type = Token.COIN
self.slot.save()
self.client.post(
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
coin.refresh_from_db()
self.assertIsNone(coin.current_room)
self.assertIsNone(coin.next_ready_at)
def test_return_restores_free_token(self):
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
expires = timezone.now() + timedelta(days=3)
self.slot.status = GateSlot.FILLED
self.slot.debited_token_type = Token.FREE
self.slot.debited_token_expires_at = expires
self.slot.save()
self.client.post(
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
restored = Token.objects.filter(user=self.gamer, token_type=Token.FREE).first()
self.assertIsNotNone(restored)
self.assertEqual(restored.expires_at, expires)
def test_return_restores_tithe_token(self):
self.slot.status = GateSlot.FILLED
self.slot.debited_token_type = Token.TITHE
self.slot.save()
self.client.post(
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
self.assertTrue(
Token.objects.filter(user=self.gamer, token_type=Token.TITHE).exists()
)
class DropTokenAvailabilityViewTest(TestCase): class DropTokenAvailabilityViewTest(TestCase):
def setUp(self): def setUp(self):

View File

@@ -9,7 +9,7 @@ urlpatterns = [
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'), path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'), path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'),
path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'), path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'),
path('room/<uuid:room_id>/gate/reject_token', views.reject_token, name='reject_token'), path('room/<uuid:room_id>/gate/return_token', views.return_token, name='return_token'),
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'), path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'), path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'), path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),

View File

@@ -6,6 +6,7 @@ from django.shortcuts import redirect, render
from django.utils import timezone from django.utils import timezone
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token
from apps.lyric.models import Token
RESERVE_TIMEOUT = timedelta(seconds=60) RESERVE_TIMEOUT = timedelta(seconds=60)
@@ -117,7 +118,7 @@ def confirm_token(request, room_id):
@login_required @login_required
def reject_token(request, room_id): def return_token(request, room_id):
if request.method == "POST": if request.method == "POST":
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
slot = room.gate_slots.filter( slot = room.gate_slots.filter(
@@ -125,10 +126,28 @@ def reject_token(request, room_id):
status__in=[GateSlot.RESERVED, GateSlot.FILLED], status__in=[GateSlot.RESERVED, GateSlot.FILLED],
).first() ).first()
if slot: if slot:
if slot.status == GateSlot.FILLED:
if slot.debited_token_type == Token.COIN:
coin = request.user.tokens.filter(
token_type=Token.COIN, current_room=room
).first()
if coin:
coin.current_room = None
coin.next_ready_at = None
coin.save()
elif slot.debited_token_type in (Token.FREE, Token.TITHE):
Token.objects.create(
user=request.user,
token_type=slot.debited_token_type,
expires_at=slot.debited_token_expires_at,
)
request.session.pop("kit_token_id", None)
slot.gamer = None slot.gamer = None
slot.status = GateSlot.EMPTY slot.status = GateSlot.EMPTY
slot.reserved_at = None slot.reserved_at = None
slot.filled_at = None slot.filled_at = None
slot.debited_token_type = None
slot.debited_token_expires_at = None
slot.save() slot.save()
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)

View File

@@ -1,6 +1,7 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Q from django.db.models import Q
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils import timezone
from apps.applets.utils import applet_context from apps.applets.utils import applet_context
from apps.applets.models import Applet, UserApplet from apps.applets.models import Applet, UserApplet
@@ -19,12 +20,15 @@ GAMEBOARD_APPLET_ORDER = [
def gameboard(request): def gameboard(request):
pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None
coin = request.user.tokens.filter(token_type=Token.COIN).first() coin = request.user.tokens.filter(token_type=Token.COIN).first()
free_tokens = list(request.user.tokens.filter(token_type=Token.FREE)) free_tokens = list(request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at"))
return render( return render(
request, "apps/gameboard/gameboard.html", { request, "apps/gameboard/gameboard.html", {
"pass_token": pass_token, "pass_token": pass_token,
"coin": coin, "coin": coin,
"free_tokens": free_tokens, "free_tokens": free_tokens,
"free_count": len(free_tokens),
"applets": applet_context(request.user, "gameboard"), "applets": applet_context(request.user, "gameboard"),
"page_class": "page-gameboard", "page_class": "page-gameboard",
"my_games": Room.objects.filter( "my_games": Room.objects.filter(
@@ -49,7 +53,12 @@ def toggle_game_applets(request):
"applets": applet_context(request.user, "gameboard"), "applets": applet_context(request.user, "gameboard"),
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None, "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), "free_tokens": list(request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at")),
"free_count": request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).count(),
"my_games": Room.objects.filter( "my_games": Room.objects.filter(
Q(owner=request.user) | Q(owner=request.user) |
Q(gate_slots__gamer=request.user) | Q(gate_slots__gamer=request.user) |

View File

@@ -1,3 +1,4 @@
from django import forms
from django.contrib import admin from django.contrib import admin
from .models import LoginToken, Token, User from .models import LoginToken, Token, User
@@ -7,6 +8,23 @@ class UserAdmin(admin.ModelAdmin):
list_display = ["email"] list_display = ["email"]
search_fields = ["email"] search_fields = ["email"]
class TokenAdminForm(forms.ModelForm):
class Meta:
model = Token
fields = "__all__"
def clean(self):
cleaned_data = super().clean()
if cleaned_data.get("token_type") == Token.FREE and not cleaned_data.get("expires_at"):
raise forms.ValidationError("Free Tokens must have an expiration date.")
return cleaned_data
class TokenAdmin(admin.ModelAdmin):
form = TokenAdminForm
admin.site.register(User, UserAdmin) admin.site.register(User, UserAdmin)
admin.site.register(LoginToken) admin.site.register(LoginToken)
admin.site.register(Token) admin.site.register(Token, TokenAdmin)

View File

@@ -1,6 +1,8 @@
from django.test import TestCase from django.test import TestCase
from django.utils import timezone
from apps.lyric.models import User from apps.lyric.admin import TokenAdminForm
from apps.lyric.models import Token, User
class UserAdminTest(TestCase): class UserAdminTest(TestCase):
@@ -23,3 +25,30 @@ class UserAdminTest(TestCase):
response = self.client.get("/admin/lyric/user/?q=admin") response = self.client.get("/admin/lyric/user/?q=admin")
self.assertContains(response, "admin@example.com") self.assertContains(response, "admin@example.com")
self.assertNotContains(response, "other@example.com") self.assertNotContains(response, "other@example.com")
class TokenAdminFormTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@example.com")
def _form(self, token_type, expires_at=None):
return TokenAdminForm(data={
"user": self.user.pk,
"token_type": token_type,
"expires_at": expires_at or "",
})
def test_free_token_without_expires_at_is_invalid(self):
form = self._form(Token.FREE)
self.assertFalse(form.is_valid())
self.assertIn("Free Tokens must have an expiration date", str(form.errors))
def test_free_token_with_expires_at_is_valid(self):
form = self._form(Token.FREE, expires_at=timezone.now())
self.assertTrue(form.is_valid())
def test_other_token_types_do_not_require_expires_at(self):
for token_type in (Token.COIN, Token.TITHE, Token.PASS):
with self.subTest(token_type=token_type):
form = self._form(token_type)
self.assertTrue(form.is_valid())

View File

@@ -59,7 +59,10 @@ class FunctionalTest(StaticLiveServerTestCase):
super().tearDown() super().tearDown()
def _test_has_failed(self): def _test_has_failed(self):
return self._outcome.result.failures or self._outcome.result.errors return any(
failure[0] == self
for failure in self._outcome.result.failures + self._outcome.result.errors
)
def take_screenshot(self): def take_screenshot(self):
path = SCREEN_DUMP_LOCATION / self._get_filename("png") path = SCREEN_DUMP_LOCATION / self._get_filename("png")

View File

@@ -314,15 +314,15 @@ class CoinSlotTest(FunctionalTest):
slot.refresh_from_db() slot.refresh_from_db()
self.assertEqual(slot.status, GateSlot.FILLED) self.assertEqual(slot.status, GateSlot.FILLED)
def test_gamer_can_reject_pending_token(self): def test_gamer_can_return_pending_token(self):
# Drop then reject via Push to Reject → slot remains empty # Drop then return via Push to Return → slot remains empty
self.browser.get(self.gate_url) self.browser.get(self.gate_url)
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails") lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails")
).click() ).click()
# Push to Reject appears in coin slot # Push to Return appears in coin slot
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-reject-btn") lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-return-btn")
).click() ).click()
# Slot 1 still empty; coin slot active again # Slot 1 still empty; coin slot active again
self.wait_for( self.wait_for(
@@ -535,6 +535,7 @@ class GameKitInsertTest(FunctionalTest):
self.assertEqual(self.browser.current_url, self.gate_url) self.assertEqual(self.browser.current_url, self.gate_url)
def test_free_token_insert_via_kit_consumed_on_confirm(self): def test_free_token_insert_via_kit_consumed_on_confirm(self):
self.gamer.tokens.filter(token_type=Token.FREE).delete()
token = Token.objects.create( token = Token.objects.create(
user=self.gamer, user=self.gamer,
token_type=Token.FREE, token_type=Token.FREE,

View File

@@ -15,6 +15,11 @@
border-radius: 50%; border-radius: 50%;
background-color: rgba(var(--priUser), 1); background-color: rgba(var(--priUser), 1);
border: 0.15rem solid rgba(var(--secUser), 1); border: 0.15rem solid rgba(var(--secUser), 1);
&.active {
color: rgba(var(--quaUser), 1);
border-color: rgba(var(--quaUser), 1);
}
} }
// ── Applet menu (shared structure) ───────────────────────── // ── Applet menu (shared structure) ─────────────────────────

View File

@@ -66,54 +66,35 @@
.kit-bag-label { .kit-bag-label {
font-size: 0.55rem; font-size: 0.55rem;
text-transform: uppercase; text-transform: uppercase;
text-decoration: underline;
letter-spacing: 0.12em; letter-spacing: 0.12em;
color: rgba(var(--secUser), 0.35); color: rgba(var(--secUser), 0.35);
writing-mode: vertical-rl; writing-mode: vertical-rl;
text-orientation: mixed; text-orientation: mixed;
transform: rotate(180deg); transform: rotate(180deg);
padding: 0 0.25rem 0 0.5rem;
} }
.kit-bag-row { .kit-bag-row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 0.4rem; gap: 0.75rem;
} }
.kit-card { #id_kit_bag_dialog {
display: flex; .token {
flex-direction: column; font-size: 1.5rem;
align-items: center; cursor: pointer;
gap: 0.15rem; transition: filter 0.15s;
padding: 0.3rem 0.4rem; padding: 0 0.125rem;
border: 0.1rem solid rgba(var(--terUser), 0.35);
border-radius: 0.4rem; &:hover .token-tooltip { display: none; } // JS positions these as fixed
cursor: pointer;
min-width: 3rem;
transition: border-color 0.15s, box-shadow 0.15s;
i {
font-size: 1.1rem;
color: rgba(var(--terUser), 0.7);
} }
.kit-card-label { .token-tooltip {
font-size: 0.5rem; z-index: 9999;
color: rgba(var(--secUser), 0.45);
text-align: center;
white-space: nowrap;
}
&:hover {
border-color: rgba(var(--terUser), 0.7);
}
&.selected {
border-color: rgba(var(--terUser), 1);
box-shadow:
0 0 0.4rem rgba(var(--terUser), 0.5),
0 0 1rem rgba(var(--terUser), 0.2)
;
i { color: rgba(var(--terUser), 1); }
} }
} }

View File

@@ -32,6 +32,11 @@ $gate-line: 2px;
gap: 0.5rem; gap: 0.5rem;
} }
// Scroll-lock when gate is open. Uses html (not body) to avoid CSS overflow
// propagation quirk on Linux headless Firefox where body overflow:hidden can
// disrupt pointer events on position:fixed descendants.
// NOTE: may be superfluous — root cause of CI kit-btn failures turned out to be
// game-kit.js missing from git (was in gitignored STATIC_ROOT only).
html:has(.gate-overlay) { html:has(.gate-overlay) {
overflow: hidden; overflow: hidden;
} }
@@ -68,6 +73,9 @@ body:has(.gate-overlay) {
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
// Prevents backdrop from intercepting clicks on position:fixed elements
// (e.g. #id_kit_btn) in Linux headless Firefox.
// NOTE: may be superfluous — see html:has comment above.
pointer-events: none; pointer-events: none;
} }
@@ -152,7 +160,7 @@ body:has(.gate-overlay) {
0 0 0.6rem rgba(var(--terUser), 0.5), 0 0 0.6rem rgba(var(--terUser), 0.5),
0 0 1.4rem rgba(var(--terUser), 0.2), 0 0 1.4rem rgba(var(--terUser), 0.2),
; ;
.token-reject-btn { text-shadow: 0 0 0.5rem rgba(var(--terUser), 0.8); } .token-return-btn { text-shadow: 0 0 0.5rem rgba(var(--terUser), 0.8); }
&:hover { &:hover {
border-color: rgba(var(--terUser), 1); border-color: rgba(var(--terUser), 1);
@@ -195,7 +203,7 @@ body:has(.gate-overlay) {
} }
} }
.token-reject-btn { .token-return-btn {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: transparent; background: transparent;
@@ -232,7 +240,7 @@ body:has(.gate-overlay) {
line-height: 1.3; line-height: 1.3;
} }
.token-reject-label { .token-return-label {
font-size: 0.55em; font-size: 0.55em;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
@@ -319,7 +327,7 @@ body:has(.gate-overlay) {
} }
// Narrow viewport — scale down, 2×3 slot grid (portrait mobile + narrow desktop) // Narrow viewport — scale down, 2×3 slot grid (portrait mobile + narrow desktop)
@media (max-width: 550px) { @media (max-width: 700px) {
.gate-modal { .gate-modal {
padding: 1.25rem 1.5rem; padding: 1.25rem 1.5rem;

View File

@@ -16,6 +16,17 @@
font-size: 0.95rem; font-size: 0.95rem;
margin: 0 0 0.3rem 0; margin: 0 0 0.3rem 0;
color: rgba(var(--terUser), 1); color: rgba(var(--terUser), 1);
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
.token-count {
font-size: 0.75rem;
opacity: 0.65;
font-weight: normal;
flex-shrink: 0;
}
} }
p { p {

View File

@@ -11,8 +11,8 @@
{% block content %} {% block content %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
<div id="id_dash_content" class="dashboard-page"> <div id="id_dash_content" class="dashboard-page">
{% include "apps/applets/_partials/_gear.html" with menu_id="id_dash_applet_menu" %}
{% include "apps/dashboard/_partials/_applets.html" %} {% include "apps/dashboard/_partials/_applets.html" %}
{% include "apps/applets/_partials/_gear.html" with menu_id="id_dash_applet_menu" %}
</div> </div>
{% endif %} {% endif %}
{% endblock content %} {% endblock content %}

View File

@@ -32,21 +32,18 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% for token in free_tokens %} {% if free_tokens %}
<div id="id_kit_free_token_{{ forloop.counter0 }}" class="token"> {% with free_tokens.0 as token %}
<div id="id_kit_free_token" class="token">
<i class="fa-solid fa-coins"></i> <i class="fa-solid fa-coins"></i>
<div class="token-tooltip"> <div class="token-tooltip">
<h4>{{ token.tooltip_name }}</h4> <h4>{{ token.tooltip_name }}{% if free_count > 1 %} <span class="token-count">(×{{ free_count }})</span>{% endif %}</h4>
<p> <p>{{ token.tooltip_description }}</p>
{{ token.tooltip_description }}
</p>
{% if token.tooltip_shoptalk %}
<small><em>{{ token.tooltip_shoptalk }}</em></small>
{% endif %}
<p class="expiry">{{ token.tooltip_expiry }}</p> <p class="expiry">{{ token.tooltip_expiry }}</p>
</div> </div>
</div> </div>
{% endfor %} {% endwith %}
{% endif %}
<div id="id_kit_card_deck" class="kit-item"><i class="fa-regular fa-id-badge"></i></div> <div id="id_kit_card_deck" class="kit-item"><i class="fa-regular fa-id-badge"></i></div>
<div id="id_kit_dice_set" class="kit-item"><i class="fa-solid fa-dice"></i></div> <div id="id_kit_dice_set" class="kit-item"><i class="fa-solid fa-dice"></i></div>
</div> </div>

View File

@@ -35,12 +35,12 @@
<div class="token-panel"> <div class="token-panel">
<div class="token-denomination">1</div> <div class="token-denomination">1</div>
<span class="token-insert-label">INSERT TOKEN TO PLAY</span> <span class="token-insert-label">INSERT TOKEN TO PLAY</span>
<span class="token-reject-label">PUSH TO REJECT</span> <span class="token-return-label">PUSH TO RETURN</span>
</div> </div>
{% if user_can_reject %} {% if user_can_reject %}
<form method="POST" action="{% url 'epic:reject_token' room.id %}" style="display:contents"> <form method="POST" action="{% url 'epic:return_token' room.id %}" style="display:contents">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="token-reject-btn" aria-label="Push to reject"></button> <button type="submit" class="token-return-btn" aria-label="Push to return"></button>
</form> </form>
{% endif %} {% endif %}
</div> </div>

View File

@@ -26,19 +26,18 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% for token in free_tokens %} {% if free_tokens %}
<div id="id_free_token_{{ forloop.counter0 }}" class="token"> {% with free_tokens.0 as token %}
<div id="id_free_token" class="token">
<i class="fa-solid fa-coins"></i> <i class="fa-solid fa-coins"></i>
<div class="token-tooltip"> <div class="token-tooltip">
<h4>{{ token.tooltip_name }}</h4> <h4>{{ token.tooltip_name }}{% if free_count > 1 %} <span class="token-count">(×{{ free_count }})</span>{% endif %}</h4>
<p>{{ token.tooltip_description }}</p> <p>{{ token.tooltip_description }}</p>
{% if token.tooltip_shoptalk %}
<small><em>{{ token.tooltip_shoptalk }}</em></small>
{% endif %}
<p class="expiry">{{ token.tooltip_expiry }}</p> <p class="expiry">{{ token.tooltip_expiry }}</p>
</div> </div>
</div> </div>
{% empty %} {% endwith %}
{% else %}
<div id="id_free_token_empty" class="token token--empty"> <div id="id_free_token_empty" class="token token--empty">
<i class="fa-solid fa-coins"></i> <i class="fa-solid fa-coins"></i>
<div class="token-tooltip"> <div class="token-tooltip">
@@ -47,20 +46,19 @@
<p class="expiry">find one around</p> <p class="expiry">find one around</p>
</div> </div>
</div> </div>
{% endfor %} {% endif %}
{% for token in tithe_tokens %} {% if tithe_tokens %}
<div id="id_tithe_token_{{ forloop.counter0 }}" class="token"> {% with tithe_tokens.0 as token %}
<div id="id_tithe_token" class="token">
<i class="fa-solid fa-piggy-bank"></i> <i class="fa-solid fa-piggy-bank"></i>
<div class="token-tooltip"> <div class="token-tooltip">
<h4>{{ token.tooltip_name }}</h4> <h4>{{ token.tooltip_name }}{% if tithe_count > 1 %} <span class="token-count">(×{{ tithe_count }})</span>{% endif %}</h4>
<p>{{ token.tooltip_description }}</p> <p>{{ token.tooltip_description }}</p>
{% if token.tooltip_shoptalk %}
<small><em>{{ token.tooltip_shoptalk }}</em></small>
{% endif %}
<p class="expiry">{{ token.tooltip_expiry }}</p> <p class="expiry">{{ token.tooltip_expiry }}</p>
</div> </div>
</div> </div>
{% empty %} {% endwith %}
{% else %}
<div id="id_tithe_token_empty" class="token token--empty"> <div id="id_tithe_token_empty" class="token token--empty">
<i class="fa-solid fa-piggy-bank"></i> <i class="fa-solid fa-piggy-bank"></i>
<div class="token-tooltip"> <div class="token-tooltip">
@@ -69,6 +67,6 @@
<p class="expiry">purchase one above</p> <p class="expiry">purchase one above</p>
</div> </div>
</div> </div>
{% endfor %} {% endif %}
</div> </div>
</section> </section>

View File

@@ -5,7 +5,7 @@
{% for token in tokens %} {% for token in tokens %}
{% if token.token_type == "coin" or token.token_type == "pass" %} {% if token.token_type == "coin" or token.token_type == "pass" %}
<div <div
class="kit-card" class="token"
draggable="true" draggable="true"
data-token-id="{{ token.id }}" data-token-id="{{ token.id }}"
data-token-type="{{ token.token_type }}" data-token-type="{{ token.token_type }}"
@@ -15,7 +15,14 @@
{% else %} {% else %}
<i class="fa-solid fa-clipboard"></i> <i class="fa-solid fa-clipboard"></i>
{% endif %} {% endif %}
<span class="kit-card-label">{{ token.tooltip_name }}</span> <div class="token-tooltip">
<h4>{{ token.tooltip_name }}</h4>
<p>{{ token.tooltip_description }}</p>
{% if token.tooltip_shoptalk %}
<small><em>{{ token.tooltip_shoptalk }}</em></small>
{% endif %}
<p class="expiry">{{ token.tooltip_expiry }}</p>
</div>
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@@ -24,23 +31,36 @@
<div class="kit-bag-section"> <div class="kit-bag-section">
<span class="kit-bag-label">Tokens</span> <span class="kit-bag-label">Tokens</span>
<div class="kit-bag-row"> <div class="kit-bag-row">
{% for token in tokens %} {% if free_token %}
{% if token.token_type == "Free" or token.token_type == "tithe" %} <div
<div class="token"
class="kit-card" draggable="true"
draggable="true" data-token-id="{{ free_token.id }}"
data-token-id="{{ token.id }}" data-token-type="{{ free_token.token_type }}"
data-token-type="{{ token.token_type }}" >
> <i class="fa-solid fa-coins"></i>
{% if token.token_type == "Free" %} <div class="token-tooltip">
<i class="fa-solid fa-coins"></i> <h4>{{ free_token.tooltip_name }}{% if free_count > 1 %} <span class="token-count">(×{{ free_count }})</span>{% endif %}</h4>
{% else %} <p>{{ free_token.tooltip_description }}</p>
<i class="fa-solid fa-piggy-bank"></i> <p class="expiry">{{ free_token.tooltip_expiry }}</p>
{% endif %}
<span class="kit-card-label">{{ token.tooltip_name }}</span>
</div> </div>
{% endif %} </div>
{% endfor %} {% endif %}
{% if tithe_token %}
<div
class="token"
draggable="true"
data-token-id="{{ tithe_token.id }}"
data-token-type="{{ tithe_token.token_type }}"
>
<i class="fa-solid fa-piggy-bank"></i>
<div class="token-tooltip">
<h4>{{ tithe_token.tooltip_name }}{% if tithe_count > 1 %} <span class="token-count">(×{{ tithe_count }})</span>{% endif %}</h4>
<p>{{ tithe_token.tooltip_description }}</p>
<p class="expiry">{{ tithe_token.tooltip_expiry }}</p>
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
{% else %} {% else %}