GAME KIT: DON|DOFF equip system — portal tooltips, kit bag sync, btn-disabled fix
- DON/DOFF buttons on left edge of game kit applet portal tooltip (mirroring FLIP/FYI) - equip-trinket/unequip-trinket/equip-deck/unequip-deck views + URLs - Portal stays open after DON/DOFF; buttons swap state in-place (_setEquipState) - _syncTokenButtons: updates all .tt DON/DOFF buttons after equip state change - _syncKitBagDialog (DOFF): replaces card with grayed placeholder icon in-place - _refreshKitDialog (DON): re-fetches kit content so newly-equipped card appears immediately - kit-content-refreshed event: game-kit.js re-attaches card listeners after re-fetch - Bounding box expanded 24px left so buttons at portal edge don't trigger close - mini-portal pinned with right (not left) so text width changes grow/shrink leftward - btn-disabled moved dead last in .btn block — wins by source order, no !important needed - Kit bag panel: trinket + token sections always render (placeholder when empty) - Backstage Pass in GameKitEquipTest setUp (is_staff, natural unequipped state) - Portal padding 0.75rem / 1.5rem; tt-description/shoptalk smaller; tt-expiry --priRd - Wallet tokens CSS hover rule for .tt removed (portal-only now) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
// gameboard.js re-fetches dialog content after DON and fires this event.
|
||||
dialog.addEventListener('kit-content-refreshed', function () {
|
||||
attachCardListeners();
|
||||
});
|
||||
|
||||
function attachCardListeners() {
|
||||
dialog.querySelectorAll('.token[data-token-id]').forEach(function (card) {
|
||||
card.addEventListener('click', function () {
|
||||
|
||||
@@ -14,7 +14,6 @@ function initGameKitTooltips() {
|
||||
portal.style.display = 'none';
|
||||
miniPortal.style.display = 'none';
|
||||
|
||||
let equippedId = gameKit.dataset.equippedId || '';
|
||||
let activeToken = null;
|
||||
let equipping = false;
|
||||
|
||||
@@ -32,7 +31,16 @@ function initGameKitTooltips() {
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (portal.classList.contains('active') && activeToken) {
|
||||
const rects = [activeToken.getBoundingClientRect(), portal.getBoundingClientRect()];
|
||||
const tokenRect = activeToken.getBoundingClientRect();
|
||||
const portalRect = portal.getBoundingClientRect();
|
||||
// Expand left to cover button overflow outside portal edge
|
||||
const expandedPortalRect = {
|
||||
left: portalRect.left - 24,
|
||||
top: portalRect.top,
|
||||
right: portalRect.right,
|
||||
bottom: portalRect.bottom,
|
||||
};
|
||||
const rects = [tokenRect, expandedPortalRect];
|
||||
if (miniPortal.classList.contains('active')) rects.push(miniPortal.getBoundingClientRect());
|
||||
const left = Math.min(...rects.map(r => r.left));
|
||||
const top = Math.min(...rects.map(r => r.top));
|
||||
@@ -50,69 +58,157 @@ function initGameKitTooltips() {
|
||||
}
|
||||
});
|
||||
|
||||
// buildMiniContent takes the full element so it can inspect data-deck-id vs data-token-id.
|
||||
// buildMiniContent — text-only status; DON/DOFF buttons live in the main portal.
|
||||
function buildMiniContent(token) {
|
||||
const deckId = token.dataset.deckId;
|
||||
const tokenId = token.dataset.tokenId;
|
||||
|
||||
const equippedId = gameKit.dataset.equippedId || '';
|
||||
const equippedDeckId = gameKit.dataset.equippedDeckId || '';
|
||||
if (deckId) {
|
||||
const equippedDeckId = gameKit.dataset.equippedDeckId || '';
|
||||
if (equippedDeckId && deckId === equippedDeckId) {
|
||||
miniPortal.textContent = 'Equipped';
|
||||
} else {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'equip-deck-btn';
|
||||
btn.textContent = 'Equip Deck?';
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
equipping = true;
|
||||
gameKit.dataset.equippedDeckId = deckId;
|
||||
fetch(`/gameboard/equip-deck/${deckId}/`, {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': getCsrfToken()},
|
||||
}).then(r => {
|
||||
if (r.ok && equipping) {
|
||||
equipping = false;
|
||||
closePortals();
|
||||
} else {
|
||||
equipping = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
miniPortal.innerHTML = '';
|
||||
miniPortal.appendChild(btn);
|
||||
}
|
||||
miniPortal.textContent = (equippedDeckId && deckId === equippedDeckId) ? 'Equipped' : 'Not Equipped';
|
||||
} else if (tokenId) {
|
||||
if (equippedId && tokenId === equippedId) {
|
||||
miniPortal.textContent = 'Equipped';
|
||||
} else {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'equip-trinket-btn';
|
||||
btn.dataset.tokenId = tokenId;
|
||||
btn.textContent = 'Equip Trinket?';
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
equipping = true;
|
||||
equippedId = tokenId;
|
||||
gameKit.dataset.equippedId = equippedId;
|
||||
fetch(`/gameboard/equip-trinket/${tokenId}/`, {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': getCsrfToken()},
|
||||
}).then(r => {
|
||||
if (r.ok && equipping) {
|
||||
equipping = false;
|
||||
closePortals();
|
||||
} else {
|
||||
equipping = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
miniPortal.innerHTML = '';
|
||||
miniPortal.appendChild(btn);
|
||||
}
|
||||
miniPortal.textContent = (equippedId && tokenId === equippedId) ? 'Equipped' : 'Not Equipped';
|
||||
}
|
||||
}
|
||||
|
||||
// Update DON/DOFF button pair to reflect new equipped state.
|
||||
function _setEquipState(donBtn, doffBtn, isEquipped) {
|
||||
if (isEquipped) {
|
||||
donBtn.classList.add('btn-disabled'); donBtn.textContent = '×';
|
||||
doffBtn.classList.remove('btn-disabled'); doffBtn.textContent = 'DOFF';
|
||||
} else {
|
||||
donBtn.classList.remove('btn-disabled'); donBtn.textContent = 'DON';
|
||||
doffBtn.classList.add('btn-disabled'); doffBtn.textContent = '×';
|
||||
}
|
||||
}
|
||||
|
||||
// Sync all tokens' .tt DON/DOFF buttons after an equip state change.
|
||||
function _syncTokenButtons(kind, newEquippedId) {
|
||||
const selector = kind === 'deck' ? '[data-deck-id]' : '[data-token-id]';
|
||||
gameKit.querySelectorAll('.token' + selector).forEach(tokenEl => {
|
||||
const id = kind === 'deck' ? tokenEl.dataset.deckId : tokenEl.dataset.tokenId;
|
||||
const tt = tokenEl.querySelector('.tt');
|
||||
if (!tt) return;
|
||||
const don = tt.querySelector('.btn-equip');
|
||||
const doff = tt.querySelector('.btn-unequip');
|
||||
if (!don || !doff) return;
|
||||
_setEquipState(don, doff, id === newEquippedId);
|
||||
});
|
||||
}
|
||||
|
||||
// If the kit bag dialog is open, re-fetch its content (used after DON so the
|
||||
// newly-equipped card appears without the user having to close+reopen).
|
||||
function _refreshKitDialog() {
|
||||
const dialog = document.getElementById('id_kit_bag_dialog');
|
||||
const kitBtn = document.getElementById('id_kit_btn');
|
||||
if (!dialog || !dialog.hasAttribute('open') || !kitBtn || !kitBtn.dataset.kitUrl) return;
|
||||
fetch(kitBtn.dataset.kitUrl, {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
})
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
dialog.innerHTML = html;
|
||||
dialog.dispatchEvent(new CustomEvent('kit-content-refreshed'));
|
||||
});
|
||||
}
|
||||
|
||||
// If the kit bag dialog is open, replace the matching card with a placeholder.
|
||||
function _syncKitBagDialog(kind, id) {
|
||||
const dialog = document.getElementById('id_kit_bag_dialog');
|
||||
if (!dialog || !dialog.hasAttribute('open')) return;
|
||||
const selector = kind === 'deck'
|
||||
? `.kit-bag-deck[data-deck-id="${id}"]`
|
||||
: `.token[data-token-id="${id}"]`;
|
||||
const card = dialog.querySelector(selector);
|
||||
if (!card) return;
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'kit-bag-placeholder';
|
||||
const icon = card.querySelector('i');
|
||||
if (icon) placeholder.innerHTML = icon.outerHTML;
|
||||
card.parentNode.insertBefore(placeholder, card);
|
||||
card.remove();
|
||||
}
|
||||
|
||||
function wireDonDoff(token) {
|
||||
const donBtn = portal.querySelector('.btn-equip');
|
||||
const doffBtn = portal.querySelector('.btn-unequip');
|
||||
if (!donBtn || !doffBtn) return;
|
||||
|
||||
donBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (donBtn.classList.contains('btn-disabled') || equipping) return;
|
||||
equipping = true;
|
||||
const tokenId = donBtn.dataset.tokenId;
|
||||
const deckId = donBtn.dataset.deckId;
|
||||
if (tokenId) {
|
||||
gameKit.dataset.equippedId = tokenId;
|
||||
fetch(`/gameboard/equip-trinket/${tokenId}/`, {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': getCsrfToken()},
|
||||
}).then(r => {
|
||||
equipping = false;
|
||||
if (r.ok) {
|
||||
_setEquipState(donBtn, doffBtn, true);
|
||||
_syncTokenButtons('trinket', tokenId);
|
||||
buildMiniContent(token);
|
||||
_refreshKitDialog();
|
||||
}
|
||||
});
|
||||
} else if (deckId) {
|
||||
gameKit.dataset.equippedDeckId = deckId;
|
||||
fetch(`/gameboard/equip-deck/${deckId}/`, {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': getCsrfToken()},
|
||||
}).then(r => {
|
||||
equipping = false;
|
||||
if (r.ok) {
|
||||
_setEquipState(donBtn, doffBtn, true);
|
||||
_syncTokenButtons('deck', deckId);
|
||||
buildMiniContent(token);
|
||||
_refreshKitDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
doffBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (doffBtn.classList.contains('btn-disabled') || equipping) return;
|
||||
equipping = true;
|
||||
const tokenId = doffBtn.dataset.tokenId;
|
||||
const deckId = doffBtn.dataset.deckId;
|
||||
if (tokenId) {
|
||||
gameKit.dataset.equippedId = '';
|
||||
fetch(`/gameboard/unequip-trinket/${tokenId}/`, {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': getCsrfToken()},
|
||||
}).then(r => {
|
||||
equipping = false;
|
||||
if (r.ok) {
|
||||
_setEquipState(donBtn, doffBtn, false);
|
||||
_syncTokenButtons('trinket', '');
|
||||
buildMiniContent(token);
|
||||
_syncKitBagDialog('token', tokenId);
|
||||
}
|
||||
});
|
||||
} else if (deckId) {
|
||||
gameKit.dataset.equippedDeckId = '';
|
||||
fetch(`/gameboard/unequip-deck/${deckId}/`, {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': getCsrfToken()},
|
||||
}).then(r => {
|
||||
equipping = false;
|
||||
if (r.ok) {
|
||||
_setEquipState(donBtn, doffBtn, false);
|
||||
_syncTokenButtons('deck', '');
|
||||
buildMiniContent(token);
|
||||
_syncKitBagDialog('deck', deckId);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showPortals(token) {
|
||||
equipping = false;
|
||||
activeToken = token;
|
||||
@@ -129,6 +225,7 @@ function initGameKitTooltips() {
|
||||
miniPortal.classList.add('active');
|
||||
miniPortal.style.display = 'block';
|
||||
miniHeight = miniPortal.offsetHeight + 4;
|
||||
wireDonDoff(token);
|
||||
} else {
|
||||
miniPortal.classList.remove('active');
|
||||
miniPortal.style.display = 'none';
|
||||
@@ -154,7 +251,10 @@ function initGameKitTooltips() {
|
||||
|
||||
if (isEquippable) {
|
||||
const mainRect = portal.getBoundingClientRect();
|
||||
miniPortal.style.left = (mainRect.right - miniPortal.offsetWidth) + 'px';
|
||||
// Pin the right edge of the mini-portal to the right edge of the main portal.
|
||||
// Using `right` (not `left`) means text width changes grow/shrink leftward.
|
||||
miniPortal.style.left = '';
|
||||
miniPortal.style.right = Math.round(window.innerWidth - mainRect.right) + 'px';
|
||||
miniPortal.style.top = (mainRect.bottom + 4) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ 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('unequip-trinket/<int:token_id>/', views.unequip_trinket, name='unequip_trinket'),
|
||||
path('unequip-deck/<int:deck_id>/', views.unequip_deck, name='unequip_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/<int:deck_id>/', views.tarot_fan, name='tarot_fan'),
|
||||
|
||||
@@ -30,8 +30,8 @@ def gameboard(request):
|
||||
"pass_token": pass_token,
|
||||
"coin": coin,
|
||||
"carte": carte,
|
||||
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""),
|
||||
"equipped_deck_id": str(request.user.equipped_deck_id or ""),
|
||||
"equipped_trinket_id": request.user.equipped_trinket_id,
|
||||
"equipped_deck_id": request.user.equipped_deck_id,
|
||||
"deck_variants": list(request.user.unlocked_decks.all()),
|
||||
"free_tokens": free_tokens,
|
||||
"free_count": len(free_tokens),
|
||||
@@ -60,8 +60,8 @@ def toggle_game_applets(request):
|
||||
"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(),
|
||||
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
||||
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""),
|
||||
"equipped_deck_id": str(request.user.equipped_deck_id or ""),
|
||||
"equipped_trinket_id": request.user.equipped_trinket_id,
|
||||
"equipped_deck_id": request.user.equipped_deck_id,
|
||||
"deck_variants": list(request.user.unlocked_decks.all()),
|
||||
"free_tokens": list(request.user.tokens.filter(
|
||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||
@@ -102,6 +102,28 @@ def equip_deck(request, deck_id):
|
||||
return HttpResponse(status=405)
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def unequip_trinket(request, token_id):
|
||||
token = get_object_or_404(Token, pk=token_id, user=request.user)
|
||||
if request.method == "POST":
|
||||
if request.user.equipped_trinket_id == token.pk:
|
||||
request.user.equipped_trinket = None
|
||||
request.user.save(update_fields=["equipped_trinket"])
|
||||
return HttpResponse(status=204)
|
||||
return HttpResponse(status=405)
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def unequip_deck(request, deck_id):
|
||||
get_object_or_404(DeckVariant, pk=deck_id)
|
||||
if request.method == "POST":
|
||||
if request.user.equipped_deck_id == deck_id:
|
||||
request.user.equipped_deck = None
|
||||
request.user.save(update_fields=["equipped_deck"])
|
||||
return HttpResponse(status=204)
|
||||
return HttpResponse(status=405)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -28,4 +28,5 @@ def tooltip(data):
|
||||
'keywords_rev':extras.get('keywords_rev'),
|
||||
'cautions': extras.get('cautions'),
|
||||
'nav': extras.get('nav'),
|
||||
'equippable': data.get('equippable'),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user