Compare commits

...

4 Commits

16 changed files with 717 additions and 34 deletions

View File

@@ -58,6 +58,22 @@
if (dialog.hasAttribute('open')) dialog.removeAttribute('open');
});
function attachTooltip(el) {
el.addEventListener('mouseenter', function () {
var tooltip = el.querySelector('.token-tooltip');
if (!tooltip) return;
var rect = el.getBoundingClientRect();
tooltip.style.position = 'fixed';
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
tooltip.style.left = rect.left + 'px';
tooltip.style.display = 'block';
});
el.addEventListener('mouseleave', function () {
var tooltip = el.querySelector('.token-tooltip');
if (tooltip) tooltip.style.display = '';
});
}
function attachCardListeners() {
dialog.querySelectorAll('.token[data-token-id]').forEach(function (card) {
card.addEventListener('click', function () {
@@ -69,22 +85,10 @@
var slot = document.querySelector('.token-slot');
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 = '';
});
attachTooltip(card);
});
dialog.querySelectorAll('.kit-bag-deck').forEach(attachTooltip);
}

View File

@@ -181,6 +181,7 @@ def kit_bag(request):
)
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
return render(request, "core/_partials/_kit_bag_panel.html", {
"equipped_deck": request.user.equipped_deck,
"equipped_trinket": request.user.equipped_trinket,
"free_token": free_tokens[0] if free_tokens else None,
"free_count": len(free_tokens),

View File

@@ -0,0 +1,106 @@
function initGameKitPage() {
const dialog = document.getElementById('id_tarot_fan_dialog');
if (!dialog) return;
const fanContent = document.getElementById('id_fan_content');
const prevBtn = document.getElementById('id_fan_prev');
const nextBtn = document.getElementById('id_fan_next');
let currentDeckId = null;
let currentIndex = 0;
let cards = [];
function storageKey(deckId) {
return 'tarot-fan-' + deckId;
}
function savePosition() {
if (currentDeckId !== null) {
sessionStorage.setItem(storageKey(currentDeckId), currentIndex);
}
}
function restorePosition(deckId) {
const saved = sessionStorage.getItem(storageKey(deckId));
return saved !== null ? parseInt(saved, 10) : 0;
}
function cardTransform(offset) {
const abs = Math.abs(offset);
return {
transform: 'translateX(' + (offset * 200) + 'px) rotateY(' + (offset * 22) + 'deg) scale(' + Math.max(0.3, 1 - abs * 0.15) + ')',
opacity: Math.max(0.15, 1 - abs * 0.25),
zIndex: 10 - abs,
};
}
function updateFan() {
const total = cards.length;
if (!total) return;
cards.forEach(function(card, i) {
let offset = i - currentIndex;
if (offset > total / 2) offset -= total;
if (offset < -total / 2) offset += total;
const abs = Math.abs(offset);
card.classList.toggle('fan-card--active', offset === 0);
if (abs > 3) {
card.style.display = 'none';
} else {
card.style.display = '';
const t = cardTransform(offset);
card.style.transform = t.transform;
card.style.opacity = t.opacity;
card.style.zIndex = t.zIndex;
}
});
}
function openFan(deckId) {
currentDeckId = deckId;
currentIndex = restorePosition(deckId);
fetch('/gameboard/game-kit/deck/' + deckId + '/')
.then(function(r) { return r.text(); })
.then(function(html) {
fanContent.innerHTML = html;
cards = Array.from(fanContent.querySelectorAll('.fan-card'));
if (currentIndex >= cards.length) currentIndex = 0;
updateFan();
dialog.showModal();
});
}
function closeFan() {
savePosition();
dialog.close();
}
function navigate(delta) {
if (!cards.length) return;
currentIndex = (currentIndex + delta + cards.length) % cards.length;
savePosition();
updateFan();
}
// Click on the dialog background (outside .tarot-fan-wrap) closes the modal
dialog.addEventListener('click', function(e) {
if (!e.target.closest('.tarot-fan-wrap')) closeFan();
});
// Arrow key navigation
dialog.addEventListener('keydown', function(e) {
if (e.key === 'ArrowRight') navigate(1);
if (e.key === 'ArrowLeft') navigate(-1);
});
prevBtn.addEventListener('click', function() { navigate(-1); });
nextBtn.addEventListener('click', function() { navigate(1); });
document.querySelectorAll('.gk-deck-card[data-deck-id]').forEach(function(card) {
card.addEventListener('click', function() { openFan(card.dataset.deckId); });
});
}
document.addEventListener('DOMContentLoaded', initGameKitPage);

View File

@@ -118,3 +118,21 @@ class EquipTrinketViewTest(TestCase):
)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/gameboard/_partials/_equip_trinket_btn.html")
class TarotFanViewTest(TestCase):
def setUp(self):
from apps.epic.models import DeckVariant
self.earthman = DeckVariant.objects.get(slug="earthman")
self.fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
self.user = User.objects.create(email="fan@test.io")
self.client.force_login(self.user)
def test_returns_fan_partial_for_unlocked_deck(self):
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/gameboard/_partials/_tarot_fan.html")
def test_returns_403_for_locked_deck(self):
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.fiorentine.pk}))
self.assertEqual(response.status_code, 403)

View File

@@ -8,5 +8,7 @@ urlpatterns = [
path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'),
path('equip-trinket/<int:token_id>/', views.equip_trinket, name='equip_trinket'),
path('equip-deck/<int:deck_id>/', views.equip_deck, name='equip_deck'),
path('game-kit/', views.game_kit, name='game_kit'),
path('game-kit/deck/<int:deck_id>/', views.tarot_fan, name='tarot_fan'),
]

View File

@@ -100,3 +100,36 @@ def equip_deck(request, deck_id):
request.user.save(update_fields=["equipped_deck"])
return HttpResponse(status=204)
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(
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", {
"coin": coin,
"pass_token": pass_token,
"carte": carte,
"free_tokens": free_tokens,
"tithe_tokens": tithe_tokens,
"unlocked_decks": list(request.user.unlocked_decks.all()),
"page_class": "page-gameboard",
})
@login_required(login_url="/")
def tarot_fan(request, deck_id):
from apps.epic.models import TarotCard
deck = get_object_or_404(DeckVariant, pk=deck_id)
if not request.user.unlocked_decks.filter(pk=deck_id).exists():
return HttpResponse(status=403)
cards = list(TarotCard.objects.filter(deck_variant=deck).order_by("arcana", "number"))
return render(request, "apps/gameboard/_partials/_tarot_fan.html", {
"deck": deck,
"cards": cards,
})

View File

@@ -342,3 +342,163 @@ class GameKitDeckSelectionTest(FunctionalTest):
self.browser.find_element(By.ID, "id_kit_earthman_deck")
fiorentine_cards = self.browser.find_elements(By.ID, "id_kit_fiorentine_deck")
self.assertEqual(len(fiorentine_cards), 0)
class GameKitPageTest(FunctionalTest):
"""
User navigates from gameboard to the dedicated game-kit page.
The page shows four rows: trinkets, tokens, card decks, dice placeholder.
Clicking a deck card opens a tarot fan modal with coverflow navigation.
"""
def setUp(self):
super().setUp()
from apps.epic.models import TarotCard
for slug, name, cols, rows in [
("new-game", "New Game", 6, 3),
("my-games", "My Games", 6, 3),
("game-kit", "Game Kit", 6, 3),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "gameboard"},
)
self.earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
# Seed 10 cards — enough to demonstrate full 7-card coverflow
for i in range(10):
TarotCard.objects.get_or_create(
deck_variant=self.earthman,
slug=f"gkp-card-{i}",
defaults={"name": f"Card {i}", "arcana": "MAJOR", "number": i},
)
# Create user after decks so signal sets equipped_deck + unlocked_decks
self.gamer = User.objects.create(email="gamer@kit.io")
self.gamer.refresh_from_db()
self.create_pre_authenticated_session("gamer@kit.io")
# ------------------------------------------------------------------ #
# Test 7 — gameboard Game Kit heading links to dedicated page #
# ------------------------------------------------------------------ #
def test_gameboard_game_kit_heading_links_to_game_kit_page(self):
self.browser.get(self.live_server_url + "/gameboard/")
link = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_applet_game_kit h2 a")
)
link.click()
self.wait_for(lambda: self.assertIn("/gameboard/game-kit/", self.browser.current_url))
# ------------------------------------------------------------------ #
# Test 8 — game-kit page shows four rows #
# ------------------------------------------------------------------ #
def test_game_kit_page_shows_four_rows(self):
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_gk_trinkets"))
self.browser.find_element(By.ID, "id_gk_tokens")
self.browser.find_element(By.ID, "id_gk_decks")
self.browser.find_element(By.ID, "id_gk_dice")
# ------------------------------------------------------------------ #
# Test 9 — clicking a deck card opens the tarot fan modal #
# ------------------------------------------------------------------ #
def test_clicking_deck_opens_tarot_fan_modal(self):
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
).click()
dialog = self.browser.find_element(By.ID, "id_tarot_fan_dialog")
self.wait_for(lambda: self.assertTrue(dialog.is_displayed()))
# ------------------------------------------------------------------ #
# Test 10 — fan shows active center card plus receding cards #
# ------------------------------------------------------------------ #
def test_fan_shows_active_card_and_receding_cards(self):
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
).click()
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active"))
visible = self.browser.find_elements(
By.CSS_SELECTOR, "#id_fan_content .fan-card:not([style*='display: none'])"
)
self.assertGreater(len(visible), 1)
# ------------------------------------------------------------------ #
# Test 11 — next button advances the active card #
# ------------------------------------------------------------------ #
def test_fan_next_button_advances_card(self):
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
).click()
first_index = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active")
).get_attribute("data-index")
self.browser.find_element(By.ID, "id_fan_next").click()
self.wait_for(
lambda: self.assertNotEqual(
self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index"),
first_index,
)
)
# ------------------------------------------------------------------ #
# Test 12 — clicking outside the modal closes it #
# ------------------------------------------------------------------ #
def test_clicking_outside_fan_closes_modal(self):
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
).click()
dialog = self.browser.find_element(By.ID, "id_tarot_fan_dialog")
self.wait_for(lambda: self.assertTrue(dialog.is_displayed()))
# Dispatch a click directly on the dialog element (simulates clicking the dark backdrop)
self.browser.execute_script(
"document.getElementById('id_tarot_fan_dialog')"
".dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}))"
)
self.wait_for(lambda: self.assertFalse(dialog.is_displayed()))
# ------------------------------------------------------------------ #
# Test 13 — reopening the modal remembers scroll position #
# ------------------------------------------------------------------ #
def test_fan_remembers_position_on_reopen(self):
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
deck_card = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
)
deck_card.click()
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active"))
# Advance 3 cards
for _ in range(3):
self.browser.find_element(By.ID, "id_fan_next").click()
saved_index = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index")
)
# Close
self.browser.execute_script(
"document.getElementById('id_tarot_fan_dialog')"
".dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}))"
)
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_tarot_fan_dialog").is_displayed()
)
)
# Reopen and verify position restored
deck_card.click()
self.wait_for(
lambda: self.assertEqual(
self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index"),
saved_index,
)
)

View File

@@ -1,3 +1,4 @@
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
@@ -62,3 +63,44 @@ class GameKitTest(FunctionalTest):
lambda: self.browser.find_element(By.ID, "id_kit_btn")
)
self.assertTrue(kit_btn.is_displayed())
def test_kit_dialog_shows_equipped_deck(self):
"""New user auto-gets Earthman equipped; kit bar shows its deck card."""
self.browser.get(self.gate_url)
self.browser.find_element(By.ID, "id_kit_btn").click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
f"#id_kit_bag_dialog .kit-bag-deck[data-deck-id='{self.gamer.equipped_deck.pk}']",
)
)
def test_kit_dialog_always_shows_dice_placeholder(self):
self.browser.get(self.gate_url)
self.browser.find_element(By.ID, "id_kit_btn").click()
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
"#id_kit_bag_dialog .kit-bag-placeholder",
)
)
def test_kit_dialog_deck_tooltip_shows_name_count_availability_and_stock_version(self):
self.browser.get(self.gate_url)
self.browser.find_element(By.ID, "id_kit_btn").click()
deck_el = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck"
)
)
ActionChains(self.browser).move_to_element(deck_el).perform()
tooltip = self.browser.find_element(
By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck .token-tooltip"
)
self.wait_for(lambda: self.assertTrue(tooltip.is_displayed()))
text = tooltip.text
self.assertIn("Earthman", text)
self.assertIn("(Default)", text)
self.assertIn("108", text)
self.assertIn("active", text)
self.assertIn("Stock version", text)

View File

@@ -219,3 +219,4 @@
#id_game_applets_container { @extend %applets-grid; }
#id_wallet_applets_container { @extend %applets-grid; }
#id_billboard_applets_container { @extend %applets-grid; }
#id_game_kit_applets_container { @extend %applets-grid; }

View File

@@ -102,16 +102,185 @@
padding: 0 0.125rem;
&:hover .token-tooltip { display: none; } // JS positions these as fixed
}
.token-tooltip {
z-index: 9999;
}
.kit-bag-deck {
font-size: 1.5rem;
cursor: pointer;
padding: 0 0.125rem;
}
.kit-bag-placeholder {
font-size: 1.5rem;
opacity: 0.3;
padding: 0 0.125rem;
}
}
.kit-bag-section--tokens {
flex: 1;
min-width: 0;
overflow: hidden;
}
.kit-bag-row--scroll {
overflow-x: auto;
flex-wrap: nowrap;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.kit-bag-empty {
font-size: 0.7rem;
color: rgba(var(--secUser), 0.4);
}
// ── Game Kit page ────────────────────────────────────────────────────────────
#id_game_kit_applets_container section {
display: flex;
flex-direction: column;
h2 { flex-shrink: 0; }
.gk-items { flex: 1; overflow-y: auto; }
}
.gk-items {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
}
.gk-deck-card,
.gk-trinket-card,
.gk-token-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 0.1rem solid rgba(var(--secUser), 0.3);
cursor: pointer;
font-size: 1.5rem;
min-width: 6rem;
text-align: center;
transition: border-color 0.15s;
span { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; }
small { font-size: 0.6rem; opacity: 0.5; }
&:hover { border-color: rgba(var(--secUser), 0.8); }
}
.gk-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 0.1rem dashed rgba(var(--secUser), 0.2);
font-size: 1.5rem;
min-width: 6rem;
text-align: center;
opacity: 0.4;
span { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; }
}
.gk-empty {
font-size: 0.8rem;
opacity: 0.45;
}
// ── Tarot fan modal ──────────────────────────────────────────────────────────
#id_tarot_fan_dialog {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: none;
background: rgba(0, 0, 0, 0.88);
overflow: hidden;
&::backdrop { display: none; } // Dialog IS the backdrop
}
.tarot-fan-wrap {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
perspective: 900px;
}
.tarot-fan {
position: relative;
width: 220px;
height: 340px;
}
.fan-card {
position: absolute;
inset: 0;
width: 220px;
height: 340px;
border-radius: 0.75rem;
background: rgba(var(--priUser), 1);
border: 0.1rem solid rgba(var(--secUser), 0.4);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.25s ease, opacity 0.25s ease;
transform-style: preserve-3d;
&--active {
border-color: rgba(var(--secUser), 1);
box-shadow: 0 0 2rem rgba(var(--secUser), 0.3);
}
}
.fan-card-face {
padding: 1.25rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 0.5rem;
.fan-card-number { font-size: 0.65rem; opacity: 0.5; }
.fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; }
.fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.6; }
.fan-card-correspondence { font-size: 0.6rem; opacity: 0.45; font-style: italic; }
}
.fan-nav {
position: absolute;
z-index: 20;
font-size: 3rem;
line-height: 1;
background: none;
border: none;
color: rgba(var(--secUser), 0.6);
cursor: pointer;
padding: 1rem;
transition: color 0.15s;
pointer-events: auto;
&:hover { color: rgba(var(--secUser), 1); }
&--prev { left: 1rem; }
&--next { right: 1rem; }
}

View File

@@ -35,6 +35,15 @@
&.expiry {
color: rgba(var(--priRd), 1);
}
&.availability {
color: rgba(var(--priRd), 1);
}
&.stock-version {
font-weight: 700;
color: rgba(var(--terUser), 1);
}
}
small {

View File

@@ -11,22 +11,21 @@
// Inline scripts can run before nested flex heights are computed, producing
// wrong scrollHeight/clientHeight values (symptom: incorrect marginTop on mobile).
requestAnimationFrame(function() {
// Push buffer so its top aligns with the bottom of the aperture when all
// events fit within the viewport (no natural scrolling).
var buffer = scroll.querySelector('.scroll-buffer');
if (buffer) {
// Push buffer so its top aligns with the bottom of the aperture when all
// events fit within the viewport (no natural scrolling needed).
var eventsHeight = scroll.scrollHeight - buffer.offsetHeight;
var gap = scroll.clientHeight - eventsHeight;
if (gap > 0) {
buffer.style.marginTop = gap + 'px';
}
}
// Only restore if there's a meaningful saved position — avoids a
// no-op scrollTop assignment (0→0) that can fire a spurious scroll
// event and reset the debounce timer in tests / headless browsers.
if ({{ scroll_position }} > 0) {
scroll.scrollTop = Math.max(0, {{ scroll_position }} - scroll.clientHeight);
// Always land with the buffer's top flush against the bottom edge —
// the user must scroll down to reveal "What happens next…?"
// Reading offsetTop here forces a synchronous reflow so the margin
// set above is already reflected in the value.
scroll.scrollTop = Math.max(0, buffer.offsetTop - scroll.clientHeight);
}
});

View File

@@ -2,7 +2,7 @@
id="id_applet_game_kit"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2>Game Kit</h2>
<h2><a href="{% url 'game_kit' %}">Game Kit</a></h2>
<div id="id_game_kit" data-equipped-id="{{ equipped_trinket_id }}" data-equipped-deck-id="{{ equipped_deck_id }}">
{% if pass_token %}
<div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}">

View File

@@ -0,0 +1,15 @@
{% for card in cards %}
<div class="fan-card" data-index="{{ forloop.counter0 }}">
<div class="fan-card-face">
<p class="fan-card-number">{{ card.number }}</p>
<h3 class="fan-card-name">{{ card.name }}</h3>
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
{% if card.correspondence %}
<p class="fan-card-correspondence">{{ card.correspondence }}</p>
{% endif %}
{% if card.suit %}
<p class="fan-card-suit">{{ card.suit }}</p>
{% endif %}
</div>
</div>
{% endfor %}

View File

@@ -0,0 +1,98 @@
{% extends "core/base.html" %}
{% load static %}
{% block title_text %}Game Kit{% endblock title_text %}
{% block header_text %}<span>Game</span>Kit{% endblock header_text %}
{% block content %}
<div class="gameboard-page">
<div id="id_game_kit_applets_container">
<section id="id_gk_trinkets" style="--applet-cols: 6; --applet-rows: 3;">
<h2>Trinkets</h2>
<div class="gk-items">
{% if pass_token %}
<div class="gk-trinket-card" data-token-id="{{ pass_token.pk }}">
<i class="fa-solid fa-clipboard"></i>
<span>{{ pass_token.tooltip_name }}</span>
</div>
{% endif %}
{% if carte %}
<div class="gk-trinket-card" data-token-id="{{ carte.pk }}">
<i class="fa-solid fa-money-check"></i>
<span>{{ carte.tooltip_name }}</span>
</div>
{% endif %}
{% if coin %}
<div class="gk-trinket-card" data-token-id="{{ coin.pk }}">
<i class="fa-solid fa-medal"></i>
<span>{{ coin.tooltip_name }}</span>
</div>
{% endif %}
{% if not pass_token and not carte and not coin %}
<p class="gk-empty"><em>No trinkets yet.</em></p>
{% endif %}
</div>
</section>
<section id="id_gk_tokens" style="--applet-cols: 6; --applet-rows: 3;">
<h2>Tokens</h2>
<div class="gk-items">
{% for token in free_tokens %}
<div class="gk-token-card" data-token-id="{{ token.pk }}">
<i class="fa-solid fa-coins"></i>
<span>{{ token.tooltip_name }}</span>
</div>
{% endfor %}
{% for token in tithe_tokens %}
<div class="gk-token-card" data-token-id="{{ token.pk }}">
<i class="fa-solid fa-hand-holding-dollar"></i>
<span>{{ token.tooltip_name }}</span>
</div>
{% endfor %}
{% if not free_tokens and not tithe_tokens %}
<p class="gk-empty"><em>No tokens yet.</em></p>
{% endif %}
</div>
</section>
<section id="id_gk_decks" style="--applet-cols: 6; --applet-rows: 3;">
<h2>Card Decks</h2>
<div class="gk-items">
{% for deck in unlocked_decks %}
<div class="gk-deck-card" data-deck-id="{{ deck.pk }}">
<i class="fa-regular fa-id-badge"></i>
<span>{{ deck.name }}</span>
<small>{{ deck.card_count }} cards</small>
</div>
{% empty %}
<p class="gk-empty"><em>No decks unlocked.</em></p>
{% endfor %}
</div>
</section>
<section id="id_gk_dice" style="--applet-cols: 6; --applet-rows: 3;">
<h2>Dice Sets</h2>
<div class="gk-items">
<div class="gk-placeholder">
<i class="fa-solid fa-dice"></i>
<span>Coming soon</span>
</div>
</div>
</section>
</div>
</div>
<dialog id="id_tarot_fan_dialog">
<div class="tarot-fan-wrap">
<button id="id_fan_prev" class="fan-nav fan-nav--prev" aria-label="Previous card">&#8249;</button>
<div id="id_fan_content" class="tarot-fan"></div>
<button id="id_fan_next" class="fan-nav fan-nav--next" aria-label="Next card">&#8250;</button>
</div>
</dialog>
{% endblock content %}
{% block scripts %}
<script src="{% static 'apps/gameboard/game-kit.js' %}"></script>
{% endblock scripts %}

View File

@@ -1,8 +1,34 @@
{% if equipped_trinket or free_token or tithe_token %}
{% if equipped_deck %}
<div class="kit-bag-section">
<span class="kit-bag-label">Trinkets</span>
<span class="kit-bag-label">Deck</span>
<div class="kit-bag-row">
<div class="kit-bag-deck" data-deck-id="{{ equipped_deck.pk }}">
<i class="fa-regular fa-id-badge"></i>
<div class="token-tooltip">
<h4>{{ equipped_deck.name }}{% if equipped_deck.is_default %} <span class="token-count">(Default)</span>{% endif %}</h4>
<p>{{ equipped_deck.card_count }}-card Tarot deck</p>
<small><em>placeholder comment</em></small>
<p class="availability">active</p>
<p class="stock-version">Stock version</p>
</div>
</div>
</div>
</div>
{% endif %}
<div class="kit-bag-section">
<span class="kit-bag-label">Dice</span>
<div class="kit-bag-row">
<div class="kit-bag-placeholder">
<i class="fa-solid fa-dice"></i>
</div>
</div>
</div>
{% if equipped_trinket %}
<div class="kit-bag-section">
<span class="kit-bag-label">Trinket</span>
<div class="kit-bag-row">
{% if equipped_trinket %}
{% with token=equipped_trinket %}
<div
class="token"
@@ -30,12 +56,14 @@
</div>
</div>
{% endwith %}
{% endif %}
</div>
</div>
<div class="kit-bag-section">
{% endif %}
{% if free_token or tithe_token %}
<div class="kit-bag-section kit-bag-section--tokens">
<span class="kit-bag-label">Tokens</span>
<div class="kit-bag-row">
<div class="kit-bag-row kit-bag-row--scroll">
{% if free_token %}
<div
class="token"
@@ -71,6 +99,4 @@
{% endif %}
</div>
</div>
{% else %}
<p class="kit-bag-empty">Kit bag empty.</p>
{% endif %}