From 41da40b803a0b15cb359bcf596e5ae618a0da081 Mon Sep 17 00:00:00 2001 From: Guillaume Juillot <710977+gjuillot@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:23:38 +0200 Subject: [PATCH 1/3] feat(cards): add Deck tab to card detail page Adds a login-gated "Deck" tab after Lore showing the user's own decks in a compact list, filterable by name and by a single active faction (defaulting to the viewed card's faction). Hovering a deck reveals an "Add to deck" button that posts to a new deck-add-card endpoint, which fetches the deck's current card list and PATCHes back the updated quantities (the decks API has no incremental add operation). Co-Authored-By: Claude Sonnet 5 --- .../core-altered-cards/api/deck-add-card.php | 101 ++++++++ plugins/core-altered-cards/assets/style.css | 16 ++ plugins/core-altered-cards/pages/card.php | 218 ++++++++++++++++++ plugins/core-altered-cards/plugin.json | 3 +- 4 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 plugins/core-altered-cards/api/deck-add-card.php diff --git a/plugins/core-altered-cards/api/deck-add-card.php b/plugins/core-altered-cards/api/deck-add-card.php new file mode 100644 index 0000000..7616877 --- /dev/null +++ b/plugins/core-altered-cards/api/deck-add-card.php @@ -0,0 +1,101 @@ + false, 'error' => 'Method not allowed', 'code' => 'DA00']); + exit; +} + +if (!kcIsLoggedIn()) { + http_response_code(401); + echo json_encode(['ok' => false, 'error' => 'Unauthorized', 'code' => 'DA01']); + exit; +} + +if (!csrfValid($_POST['csrf_token'] ?? '')) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Invalid token', 'code' => 'DA02']); + exit; +} + +$deckId = trim($_POST['deck_id'] ?? ''); +$cardRef = trim($_POST['card_ref'] ?? ''); +if ($deckId === '' || !preg_match('/^[A-Za-z0-9_-]+$/', $cardRef)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Invalid parameters', 'code' => 'DA03']); + exit; +} + +$token = deckApiToken(); +if (!$token) { + http_response_code(401); + echo json_encode(['ok' => false, 'error' => 'Could not connect to the deck API.', 'code' => 'DA04']); + exit; +} + +// Fetch the deck's current card list +$ch = curl_init(DECKS_API_URL . '/api/decks/' . rawurlencode($deckId)); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['Accept: application/json', 'Authorization: Bearer ' . $token], + CURLOPT_TIMEOUT => 10, +]); +$getResp = curl_exec($ch); +$getCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if ($getCode < 200 || $getCode >= 300 || !$getResp) { + http_response_code($getCode ?: 502); + echo json_encode(['ok' => false, 'error' => 'Could not load the deck.', 'code' => 'DA05']); + exit; +} + +$deck = json_decode($getResp, true); +$existingCards = $deck['deckCards'] ?? $deck['cards'] ?? []; + +$deckCards = []; +$found = false; +foreach ($existingCards as $c) { + $ref = $c['cardReference'] ?? ''; + $qty = (int)($c['quantity'] ?? 1); + if ($ref === $cardRef) { + $qty++; + $found = true; + } + $deckCards[] = ['cardReference' => $ref, 'quantity' => $qty]; +} +if (!$found) { + $deckCards[] = ['cardReference' => $cardRef, 'quantity' => 1]; +} + +$ch = curl_init(DECKS_API_URL . '/api/decks/' . rawurlencode($deckId)); +curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'PATCH', + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/merge-patch+json', + 'Accept: application/json', + 'Authorization: Bearer ' . $token, + ], + CURLOPT_POSTFIELDS => json_encode(['deckCards' => $deckCards]), + CURLOPT_TIMEOUT => 15, +]); +$patchResp = curl_exec($ch); +$patchCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); + +if ($patchCode >= 200 && $patchCode < 300) { + echo json_encode(['ok' => true, 'deck_id' => $deckId, 'card_ref' => $cardRef]); +} else { + http_response_code($patchCode ?: 502); + echo json_encode(['ok' => false, 'error' => 'Could not save the deck.', 'code' => 'DA06']); +} diff --git a/plugins/core-altered-cards/assets/style.css b/plugins/core-altered-cards/assets/style.css index 6a53df6..63e524b 100644 --- a/plugins/core-altered-cards/assets/style.css +++ b/plugins/core-altered-cards/assets/style.css @@ -163,6 +163,22 @@ .card-skeleton { background:var(--sand-200); border-radius:4px; animation:card-skeleton-pulse 1.4s ease-in-out infinite; } @keyframes card-skeleton-pulse { 0%,100% { opacity:1; } 50% { opacity:.45; } } +/* Card detail: "Deck" tab — compact list of the user's own decks */ +.deck-compact-row { position:relative; display:flex; align-items:center; gap:.6rem; padding:.5rem .6rem; border:1px solid var(--sand-200); border-radius:8px; margin-bottom:.5rem; transition:background .15s; } +.deck-compact-row:hover { background:var(--sand-100); } +.deck-compact-link { position:absolute; inset:0; z-index:1; } +.deck-compact-faction { width:24px; height:24px; object-fit:contain; flex-shrink:0; } +.deck-compact-body { min-width:0; flex:1 1 auto; } +.deck-compact-name { font-size:.88rem; font-weight:700; color:var(--neutral-800); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.deck-compact-meta { display:flex; align-items:center; gap:.35rem; font-size:.78rem; color:var(--neutral-500); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.deck-compact-add-btn { position:relative; z-index:2; flex-shrink:0; border:1px solid var(--primary-400); background:var(--primary-400); color:#fff; border-radius:6px; padding:.3rem .65rem; font-size:.78rem; font-weight:600; cursor:pointer; opacity:0; pointer-events:none; transition:opacity .15s; } +.deck-compact-row:hover .deck-compact-add-btn { opacity:1; pointer-events:auto; } +.deck-compact-add-btn:disabled { cursor:default; } +.deck-compact-add-btn--ok { background:#22c55e; border-color:#22c55e; } +.deck-compact-add-btn--err { background:#dc2626; border-color:#dc2626; } +/* Touch devices have no hover: always show the add button */ +@media (hover: none) { .deck-compact-add-btn { opacity:1; pointer-events:auto; } } + /* ========================================================= Cards browser (cards.php) ========================================================= */ diff --git a/plugins/core-altered-cards/pages/card.php b/plugins/core-altered-cards/pages/card.php index b180e03..a0494cd 100644 --- a/plugins/core-altered-cards/pages/card.php +++ b/plugins/core-altered-cards/pages/card.php @@ -2,6 +2,7 @@ require_once __DIR__ . '/../includes/functions.php'; $lang = getLang(); $uiLang = getUiLang(); +$isLoggedIn = kcIsLoggedIn(); // Unique cards: renderer only supports en/fr. Non-unique: API supports en/fr/de/it/es. $_refParts = explode('_', trim($_GET['ref'] ?? '')); @@ -38,6 +39,15 @@ 'tab_rules' => 'Rules', 'tab_altered' => 'Altered Cards', 'tab_lore' => 'Lore', + 'tab_deck' => 'Deck', + 'deck_search_ph' => 'Search a deck…', + 'deck_no_decks' => 'You have no decks yet.', + 'deck_no_match' => 'No decks match these filters.', + 'deck_cards' => 'cards', + 'deck_add_btn' => 'Add to deck', + 'deck_added_ok' => 'Added!', + 'deck_add_err' => 'Error', + 'deck_view_all' => 'View all my decks', 'lbl_set' => 'Set', 'lbl_ref' => 'References', 'lbl_keywords' => 'Keywords', @@ -82,6 +92,15 @@ 'tab_rules' => 'Règles', 'tab_altered' => 'Cartes Altérées', 'tab_lore' => 'Lore', + 'tab_deck' => 'Deck', + 'deck_search_ph' => 'Rechercher un deck…', + 'deck_no_decks' => 'Vous n\'avez pas encore de deck.', + 'deck_no_match' => 'Aucun deck ne correspond à ces filtres.', + 'deck_cards' => 'cartes', + 'deck_add_btn' => 'Ajouter au deck', + 'deck_added_ok' => 'Ajouté !', + 'deck_add_err' => 'Erreur', + 'deck_view_all' => 'Voir tous mes decks', 'lbl_set' => 'Set', 'lbl_ref' => 'Références', 'lbl_keywords' => 'Mots-clés', @@ -120,6 +139,11 @@ $_assetParts = explode('_', $ref); $_assetSet = $_assetParts[1] ?? ''; $_assetRef = $_refIsUnique ? preg_replace('/_\d+$/', '', $ref) : $ref; +$_cardFactionCode = preg_match('/^ALT_[^_]+_[^_]+_([A-Z]{2})_/', $ref, $_fm) ? $_fm[1] : ''; +if (!isset($factionsData[$_cardFactionCode])) $_cardFactionCode = ''; +// Deck tab faction filter is single-select and always has one active — fall back +// to the first known faction when the card's own faction can't be derived. +$_deckDefaultFaction = $_cardFactionCode !== '' ? $_cardFactionCode : (array_key_first($factionsData) ?? ''); $pageTitle = $txt['page_title']; $pageImage = $ref ? CDN_URL . '/cards/assets/' . $_assetSet . '/' . $_assetRef . '.webp' : ''; @@ -195,6 +219,11 @@ class="card-view-img" alt=""> + + +
@@ -243,6 +272,35 @@ class="card-view-img" alt="">
+ + +
+
+
+ +
+
+ $_dfData): ?> + + +
+ + + +
+ +
+
+ + @@ -272,6 +330,11 @@ class="card-view-img" alt=""> sets: , subtypes: , formats: , + isLoggedIn: , + csrf: , + deckAddUrl: , + cardFactionCode: , + deckDefaultFaction: , txt: $txt['not_found'], 'err_api' => $txt['err_api'], @@ -297,6 +360,14 @@ class="card-view-img" alt=""> 'detail_label' => $txt['detail_label'], 'loading' => $txt['loading'], 'types' => $txt['types'], + 'deck_search_ph' => $txt['deck_search_ph'], + 'deck_no_decks' => $txt['deck_no_decks'], + 'deck_no_match' => $txt['deck_no_match'], + 'deck_cards' => $txt['deck_cards'], + 'deck_add_btn' => $txt['deck_add_btn'], + 'deck_added_ok' => $txt['deck_added_ok'], + 'deck_add_err' => $txt['deck_add_err'], + 'deck_view_all' => $txt['deck_view_all'], ]) ?>, }; @@ -838,6 +909,153 @@ function renderAlteredGrid(grid, altCards, cardName, l) { grid.appendChild(searchLink); } + // Deck tab: user's own decks, lazy-loaded on first tab click + var deckTabBtn = document.getElementById('tab-deck-btn'); + if (deckTabBtn && AlteredCard.isLoggedIn) { + var deckLoaded = false; + var deckAllDecks = []; + var deckCurrentFaction = AlteredCard.deckDefaultFaction || ''; + var deckSearchEl = document.getElementById('deck-tab-search'); + var deckFactionRow = document.getElementById('deck-tab-faction-row'); + var deckListEl = document.getElementById('deck-tab-list'); + var deckLoadingEl = document.getElementById('deck-tab-loading'); + var deckErrorEl = document.getElementById('deck-tab-error'); + var deckEmptyEl = document.getElementById('deck-tab-empty'); + var deckViewAllEl = document.getElementById('deck-tab-viewall'); + + deckTabBtn.addEventListener('click', function () { + if (!deckLoaded) { deckLoaded = true; loadMyDecksForCard(); } + }); + + if (deckFactionRow) { + deckFactionRow.addEventListener('click', function (e) { + var btn = e.target.closest('.filter-toggle'); + if (!btn) return; + deckFactionRow.querySelectorAll('.filter-toggle').forEach(function (b) { b.classList.remove('active'); }); + btn.classList.add('active'); + deckCurrentFaction = btn.dataset.faction || ''; + if (deckLoaded) loadMyDecksForCard(); + }); + } + + if (deckSearchEl) { + deckSearchEl.addEventListener('input', function () { renderDeckList(); }); + } + + function loadMyDecksForCard() { + deckLoadingEl.style.display = ''; + deckErrorEl.style.display = 'none'; + deckEmptyEl.style.display = 'none'; + deckViewAllEl.style.display = 'none'; + deckListEl.innerHTML = ''; + + var url = BASE + '/pages/decks?ajax=my&page=1'; + if (deckCurrentFaction) url += '&faction=' + encodeURIComponent(deckCurrentFaction); + + fetch(url, { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.json(); }) + .then(function (data) { + deckLoadingEl.style.display = 'none'; + if (data.error) { deckErrorEl.textContent = txt.err_connect; deckErrorEl.style.display = ''; return; } + deckAllDecks = data.member || data.data || (Array.isArray(data) ? data : []); + var pagination = data.pagination || {}; + var total = pagination.totalItems || data.totalItems || deckAllDecks.length; + deckViewAllEl.style.display = (total > deckAllDecks.length) ? '' : 'none'; + renderDeckList(); + }) + .catch(function () { + deckLoadingEl.style.display = 'none'; + deckErrorEl.textContent = txt.err_connect; + deckErrorEl.style.display = ''; + }); + } + + function renderDeckList() { + var q = deckSearchEl ? deckSearchEl.value.trim().toLowerCase() : ''; + var decks = q ? deckAllDecks.filter(function (d) { return (d.name || '').toLowerCase().indexOf(q) >= 0; }) : deckAllDecks; + + deckListEl.innerHTML = ''; + if (!deckAllDecks.length) { + deckEmptyEl.textContent = txt.deck_no_decks; + deckEmptyEl.style.display = ''; + return; + } + if (!decks.length) { + deckEmptyEl.textContent = txt.deck_no_match; + deckEmptyEl.style.display = ''; + return; + } + deckEmptyEl.style.display = 'none'; + decks.forEach(function (deck) { deckListEl.insertAdjacentHTML('beforeend', renderDeckCompactRow(deck)); }); + } + + function renderDeckCompactRow(deck) { + var deckId = deck.id || ''; + var name = deck.name || ''; + var fmt = (deck.format || 'standard').toLowerCase(); + var fmtData = (AlteredCard.formats || {})[fmt] || {}; + var fmtLabel = fmtData.label || fmt; + var fmtColor = fmtData.color || 'var(--neutral-400)'; + var stats = deck.stats || {}; + var hero = stats.hero || {}; + var heroRef = hero.reference || ''; + var heroName = hero.name || ''; + var totalCards = stats.totalCards != null ? stats.totalCards : null; + + var factionCode = ''; + var fm = heroRef.match(/^ALT_[^_]+_[^_]+_([A-Z]{2})_/); + if (fm) factionCode = fm[1]; + var factionImg = factionCode ? BASE + '/plugins/core-altered-cards/assets/faction/' + factionCode + '.png' : ''; + + return '
' + + '' + + (factionImg ? '' + escAttr(factionCode) + '' : '') + + '
' + + '
' + escHtml(name) + '
' + + '
' + + (heroName ? escHtml(heroName) + ' · ' : '') + + (totalCards !== null ? totalCards + ' ' + escHtml(txt.deck_cards) + ' ' : '') + + '' + escHtml(fmtLabel) + '' + + '
' + + '' + + '
'; + } + + if (deckListEl) { + deckListEl.addEventListener('click', function (e) { + var btn = e.target.closest('.deck-compact-add-btn'); + if (!btn || btn.disabled) return; + var originalText = txt.deck_add_btn; + btn.disabled = true; + btn.textContent = '…'; + var body = new URLSearchParams(); + body.append('deck_id', btn.dataset.deckId); + body.append('card_ref', ref); + body.append('csrf_token', AlteredCard.csrf); + fetch(AlteredCard.deckAddUrl, { method: 'POST', body: body }) + .then(function (r) { return r.json(); }) + .then(function (data) { + btn.textContent = data.ok ? txt.deck_added_ok : txt.deck_add_err; + btn.classList.add(data.ok ? 'deck-compact-add-btn--ok' : 'deck-compact-add-btn--err'); + setTimeout(function () { + btn.textContent = originalText; + btn.classList.remove('deck-compact-add-btn--ok', 'deck-compact-add-btn--err'); + btn.disabled = false; + }, 1500); + }) + .catch(function () { + btn.textContent = txt.deck_add_err; + btn.classList.add('deck-compact-add-btn--err'); + setTimeout(function () { + btn.textContent = originalText; + btn.classList.remove('deck-compact-add-btn--err'); + btn.disabled = false; + }, 1500); + }); + }); + } + } + // lightbox var lbModal = document.getElementById('ac-lightbox'); var lbInner = document.getElementById('ac-lightbox-inner'); diff --git a/plugins/core-altered-cards/plugin.json b/plugins/core-altered-cards/plugin.json index f385d88..11c0b18 100644 --- a/plugins/core-altered-cards/plugin.json +++ b/plugins/core-altered-cards/plugin.json @@ -28,7 +28,8 @@ {"endpoint": "playset", "file": "api/playset.php"}, {"endpoint": "playset-cards", "file": "api/playset-cards.php"}, {"endpoint": "favorites-search", "file": "api/favorites-search.php"}, - {"endpoint": "favorites-toggle", "file": "api/favorites-toggle.php"} + {"endpoint": "favorites-toggle", "file": "api/favorites-toggle.php"}, + {"endpoint": "deck-add-card", "file": "api/deck-add-card.php"} ], "admin": [ From 0c5c59e357311936e5c7e8663c851ee4ed2ec426 Mon Sep 17 00:00:00 2001 From: Guillaume Juillot <710977+gjuillot@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:04:42 +0200 Subject: [PATCH 2/3] fix(cards): polish Deck tab filters, pagination and add-to-deck feedback - Merge the name search and faction filter onto one row to save vertical space. - Fetch every page of the user's decks instead of only the first 21. - Show the faction icon/name in the "no decks" empty state. - Freeze a deck's row permanently in the "Added" state once a card is added, instead of reverting it a few seconds later and re-offering "Add to deck" on hover. - Drop the now-unused "view all my decks" link (superseded by full pagination). Co-Authored-By: Claude Sonnet 5 --- plugins/core-altered-cards/assets/style.css | 3 + plugins/core-altered-cards/pages/card.php | 121 +++++++++++++------- 2 files changed, 83 insertions(+), 41 deletions(-) diff --git a/plugins/core-altered-cards/assets/style.css b/plugins/core-altered-cards/assets/style.css index 63e524b..5cf75b3 100644 --- a/plugins/core-altered-cards/assets/style.css +++ b/plugins/core-altered-cards/assets/style.css @@ -174,6 +174,9 @@ .deck-compact-add-btn { position:relative; z-index:2; flex-shrink:0; border:1px solid var(--primary-400); background:var(--primary-400); color:#fff; border-radius:6px; padding:.3rem .65rem; font-size:.78rem; font-weight:600; cursor:pointer; opacity:0; pointer-events:none; transition:opacity .15s; } .deck-compact-row:hover .deck-compact-add-btn { opacity:1; pointer-events:auto; } .deck-compact-add-btn:disabled { cursor:default; } +/* Confirmation/error state stays visible until its timeout expires, even once the + mouse leaves the row (overrides the hover-only reveal above). */ +.deck-compact-add-btn--ok, .deck-compact-add-btn--err { opacity:1 !important; pointer-events:none !important; } .deck-compact-add-btn--ok { background:#22c55e; border-color:#22c55e; } .deck-compact-add-btn--err { background:#dc2626; border-color:#dc2626; } /* Touch devices have no hover: always show the add button */ diff --git a/plugins/core-altered-cards/pages/card.php b/plugins/core-altered-cards/pages/card.php index a0494cd..963e411 100644 --- a/plugins/core-altered-cards/pages/card.php +++ b/plugins/core-altered-cards/pages/card.php @@ -41,13 +41,12 @@ 'tab_lore' => 'Lore', 'tab_deck' => 'Deck', 'deck_search_ph' => 'Search a deck…', - 'deck_no_decks' => 'You have no decks yet.', + 'deck_no_decks' => 'You have no decks yet in', 'deck_no_match' => 'No decks match these filters.', 'deck_cards' => 'cards', 'deck_add_btn' => 'Add to deck', 'deck_added_ok' => 'Added!', 'deck_add_err' => 'Error', - 'deck_view_all' => 'View all my decks', 'lbl_set' => 'Set', 'lbl_ref' => 'References', 'lbl_keywords' => 'Keywords', @@ -94,13 +93,12 @@ 'tab_lore' => 'Lore', 'tab_deck' => 'Deck', 'deck_search_ph' => 'Rechercher un deck…', - 'deck_no_decks' => 'Vous n\'avez pas encore de deck.', + 'deck_no_decks' => 'Vous n\'avez pas encore de deck', 'deck_no_match' => 'Aucun deck ne correspond à ces filtres.', 'deck_cards' => 'cartes', 'deck_add_btn' => 'Ajouter au deck', 'deck_added_ok' => 'Ajouté !', 'deck_add_err' => 'Erreur', - 'deck_view_all' => 'Voir tous mes decks', 'lbl_set' => 'Set', 'lbl_ref' => 'Références', 'lbl_keywords' => 'Mots-clés', @@ -276,17 +274,17 @@ class="card-view-img" alt="">
-
+
-
-
- $_dfData): ?> - - +
+ $_dfData): ?> + + +
@@ -367,7 +362,6 @@ class="card-view-img" alt=""> 'deck_add_btn' => $txt['deck_add_btn'], 'deck_added_ok' => $txt['deck_added_ok'], 'deck_add_err' => $txt['deck_add_err'], - 'deck_view_all' => $txt['deck_view_all'], ]) ?>, }; @@ -914,6 +908,7 @@ function renderAlteredGrid(grid, altCards, cardName, l) { if (deckTabBtn && AlteredCard.isLoggedIn) { var deckLoaded = false; var deckAllDecks = []; + var deckAddedIds = {}; // deckId -> true, once a card was successfully added — frozen permanently var deckCurrentFaction = AlteredCard.deckDefaultFaction || ''; var deckSearchEl = document.getElementById('deck-tab-search'); var deckFactionRow = document.getElementById('deck-tab-faction-row'); @@ -921,7 +916,6 @@ function renderAlteredGrid(grid, altCards, cardName, l) { var deckLoadingEl = document.getElementById('deck-tab-loading'); var deckErrorEl = document.getElementById('deck-tab-error'); var deckEmptyEl = document.getElementById('deck-tab-empty'); - var deckViewAllEl = document.getElementById('deck-tab-viewall'); deckTabBtn.addEventListener('click', function () { if (!deckLoaded) { deckLoaded = true; loadMyDecksForCard(); } @@ -946,22 +940,38 @@ function loadMyDecksForCard() { deckLoadingEl.style.display = ''; deckErrorEl.style.display = 'none'; deckEmptyEl.style.display = 'none'; - deckViewAllEl.style.display = 'none'; deckListEl.innerHTML = ''; + deckAllDecks = []; + fetchDeckPage(1); + } - var url = BASE + '/pages/decks?ajax=my&page=1'; + function fetchDeckPage(page) { + var url = BASE + '/pages/decks?ajax=my&page=' + page; if (deckCurrentFaction) url += '&faction=' + encodeURIComponent(deckCurrentFaction); fetch(url, { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.json(); }) .then(function (data) { - deckLoadingEl.style.display = 'none'; - if (data.error) { deckErrorEl.textContent = txt.err_connect; deckErrorEl.style.display = ''; return; } - deckAllDecks = data.member || data.data || (Array.isArray(data) ? data : []); + if (data.error) { + deckLoadingEl.style.display = 'none'; + deckErrorEl.textContent = txt.err_connect; + deckErrorEl.style.display = ''; + return; + } + var decks = data.member || data.data || (Array.isArray(data) ? data : []); + deckAllDecks = deckAllDecks.concat(decks); var pagination = data.pagination || {}; - var total = pagination.totalItems || data.totalItems || deckAllDecks.length; - deckViewAllEl.style.display = (total > deckAllDecks.length) ? '' : 'none'; - renderDeckList(); + var total = pagination.totalItems || data.totalItems || null; + // decks.php's ?ajax=my hardcodes itemsPerPage=21 — fetch every page so the + // compact list is never silently truncated. Without reliable pagination + // metadata, fall back to "a full page came back, there might be more". + var morePages = total !== null ? (deckAllDecks.length < total) : (decks.length === 21); + if (decks.length && morePages) { + fetchDeckPage(page + 1); + } else { + deckLoadingEl.style.display = 'none'; + renderDeckList(); + } }) .catch(function () { deckLoadingEl.style.display = 'none'; @@ -970,13 +980,30 @@ function loadMyDecksForCard() { }); } + // Used to render each row's faction icon (the actual filtering is done + // server-side via the &faction= param above). + function deckFactionCode(deck) { + var heroRef = ((deck.stats || {}).hero || {}).reference || ''; + var m = heroRef.match(/^ALT_[^_]+_[^_]+_([A-Z]{2})_/); + return m ? m[1] : ''; + } + + function deckEmptyFactionHtml() { + var fData = (AlteredCard.factions || {})[deckCurrentFaction] || {}; + var fName = fData[uiLang] || fData.en || deckCurrentFaction; + var fImg = deckCurrentFaction ? BASE + '/plugins/core-altered-cards/assets/faction/' + deckCurrentFaction + '.png' : ''; + return escHtml(txt.deck_no_decks) + + (fImg ? ' ' + escAttr(deckCurrentFaction) + '' : '') + + ' ' + escHtml(fName); + } + function renderDeckList() { var q = deckSearchEl ? deckSearchEl.value.trim().toLowerCase() : ''; var decks = q ? deckAllDecks.filter(function (d) { return (d.name || '').toLowerCase().indexOf(q) >= 0; }) : deckAllDecks; deckListEl.innerHTML = ''; if (!deckAllDecks.length) { - deckEmptyEl.textContent = txt.deck_no_decks; + deckEmptyEl.innerHTML = deckEmptyFactionHtml(); deckEmptyEl.style.display = ''; return; } @@ -998,14 +1025,16 @@ function renderDeckCompactRow(deck) { var fmtColor = fmtData.color || 'var(--neutral-400)'; var stats = deck.stats || {}; var hero = stats.hero || {}; - var heroRef = hero.reference || ''; var heroName = hero.name || ''; var totalCards = stats.totalCards != null ? stats.totalCards : null; - var factionCode = ''; - var fm = heroRef.match(/^ALT_[^_]+_[^_]+_([A-Z]{2})_/); - if (fm) factionCode = fm[1]; - var factionImg = factionCode ? BASE + '/plugins/core-altered-cards/assets/faction/' + factionCode + '.png' : ''; + var factionCode = deckFactionCode(deck); + var factionImg = factionCode ? BASE + '/plugins/core-altered-cards/assets/faction/' + factionCode + '.png' : ''; + + var added = !!deckAddedIds[deckId]; + var btnHtml = added + ? '' + : ''; return '
' + '' @@ -1017,7 +1046,7 @@ function renderDeckCompactRow(deck) { + (totalCards !== null ? totalCards + ' ' + escHtml(txt.deck_cards) + ' ' : '') + '' + escHtml(fmtLabel) + '' + '
' - + '' + + btnHtml + '
'; } @@ -1025,23 +1054,33 @@ function renderDeckCompactRow(deck) { deckListEl.addEventListener('click', function (e) { var btn = e.target.closest('.deck-compact-add-btn'); if (!btn || btn.disabled) return; + var deckId = btn.dataset.deckId; var originalText = txt.deck_add_btn; btn.disabled = true; btn.textContent = '…'; var body = new URLSearchParams(); - body.append('deck_id', btn.dataset.deckId); + body.append('deck_id', deckId); body.append('card_ref', ref); body.append('csrf_token', AlteredCard.csrf); fetch(AlteredCard.deckAddUrl, { method: 'POST', body: body }) .then(function (r) { return r.json(); }) .then(function (data) { - btn.textContent = data.ok ? txt.deck_added_ok : txt.deck_add_err; - btn.classList.add(data.ok ? 'deck-compact-add-btn--ok' : 'deck-compact-add-btn--err'); - setTimeout(function () { - btn.textContent = originalText; - btn.classList.remove('deck-compact-add-btn--ok', 'deck-compact-add-btn--err'); - btn.disabled = false; - }, 1500); + if (data.ok) { + // Frozen permanently: no revert timeout, no re-enable — the deck + // is done for this card, even after the mouse leaves the row or + // the list re-renders (search/faction filter). + deckAddedIds[deckId] = true; + btn.textContent = txt.deck_added_ok; + btn.classList.add('deck-compact-add-btn--ok'); + } else { + btn.textContent = txt.deck_add_err; + btn.classList.add('deck-compact-add-btn--err'); + setTimeout(function () { + btn.textContent = originalText; + btn.classList.remove('deck-compact-add-btn--err'); + btn.disabled = false; + }, 1500); + } }) .catch(function () { btn.textContent = txt.deck_add_err; From a3b796a99eca818d635419a17de4089c0d49a1d9 Mon Sep 17 00:00:00 2001 From: Guillaume Juillot <710977+gjuillot@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:36:11 +0200 Subject: [PATCH 3/3] fix(cards): always show the Deck tab's "Add to deck" button Hover-to-reveal made the action hard to discover, especially on the touch devices the previous hover fallback was compensating for. The button now stays visible; it only changes appearance once clicked (frozen "Added" state). Co-Authored-By: Claude Sonnet 5 --- plugins/core-altered-cards/assets/style.css | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/plugins/core-altered-cards/assets/style.css b/plugins/core-altered-cards/assets/style.css index 5cf75b3..6bee4fe 100644 --- a/plugins/core-altered-cards/assets/style.css +++ b/plugins/core-altered-cards/assets/style.css @@ -171,16 +171,10 @@ .deck-compact-body { min-width:0; flex:1 1 auto; } .deck-compact-name { font-size:.88rem; font-weight:700; color:var(--neutral-800); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .deck-compact-meta { display:flex; align-items:center; gap:.35rem; font-size:.78rem; color:var(--neutral-500); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } -.deck-compact-add-btn { position:relative; z-index:2; flex-shrink:0; border:1px solid var(--primary-400); background:var(--primary-400); color:#fff; border-radius:6px; padding:.3rem .65rem; font-size:.78rem; font-weight:600; cursor:pointer; opacity:0; pointer-events:none; transition:opacity .15s; } -.deck-compact-row:hover .deck-compact-add-btn { opacity:1; pointer-events:auto; } -.deck-compact-add-btn:disabled { cursor:default; } -/* Confirmation/error state stays visible until its timeout expires, even once the - mouse leaves the row (overrides the hover-only reveal above). */ -.deck-compact-add-btn--ok, .deck-compact-add-btn--err { opacity:1 !important; pointer-events:none !important; } +.deck-compact-add-btn { position:relative; z-index:2; flex-shrink:0; border:1px solid var(--primary-400); background:var(--primary-400); color:#fff; border-radius:6px; padding:.3rem .65rem; font-size:.78rem; font-weight:600; cursor:pointer; } +.deck-compact-add-btn:disabled { cursor:default; pointer-events:none; } .deck-compact-add-btn--ok { background:#22c55e; border-color:#22c55e; } .deck-compact-add-btn--err { background:#dc2626; border-color:#dc2626; } -/* Touch devices have no hover: always show the add button */ -@media (hover: none) { .deck-compact-add-btn { opacity:1; pointer-events:auto; } } /* ========================================================= Cards browser (cards.php)