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..6bee4fe 100644 --- a/plugins/core-altered-cards/assets/style.css +++ b/plugins/core-altered-cards/assets/style.css @@ -163,6 +163,19 @@ .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; } +.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; } + /* ========================================================= Cards browser (cards.php) ========================================================= */ diff --git a/plugins/core-altered-cards/pages/card.php b/plugins/core-altered-cards/pages/card.php index b180e03..963e411 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,14 @@ '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 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', 'lbl_set' => 'Set', 'lbl_ref' => 'References', 'lbl_keywords' => 'Keywords', @@ -82,6 +91,14 @@ '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', 'lbl_set' => 'Set', 'lbl_ref' => 'Références', 'lbl_keywords' => 'Mots-clés', @@ -120,6 +137,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 +217,11 @@ class="card-view-img" alt=""> + + +
@@ -243,6 +270,32 @@ class="card-view-img" alt="">
+ + +
+
+
+ +
+ $_dfData): ?> + + +
+
+ + + +
+
+
+ + @@ -272,6 +325,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 +355,13 @@ 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'], ]) ?>, }; @@ -838,6 +903,198 @@ 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 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'); + 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'); + + 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'; + deckListEl.innerHTML = ''; + deckAllDecks = []; + fetchDeckPage(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) { + 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 || 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'; + deckErrorEl.textContent = txt.err_connect; + deckErrorEl.style.display = ''; + }); + } + + // 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.innerHTML = deckEmptyFactionHtml(); + 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 heroName = hero.name || ''; + var totalCards = stats.totalCards != null ? stats.totalCards : null; + + var factionCode = deckFactionCode(deck); + var factionImg = factionCode ? BASE + '/plugins/core-altered-cards/assets/faction/' + factionCode + '.png' : ''; + + var added = !!deckAddedIds[deckId]; + var btnHtml = added + ? '' + : ''; + + return '
' + + '' + + (factionImg ? '' + escAttr(factionCode) + '' : '') + + '
' + + '
' + escHtml(name) + '
' + + '
' + + (heroName ? escHtml(heroName) + ' · ' : '') + + (totalCards !== null ? totalCards + ' ' + escHtml(txt.deck_cards) + ' ' : '') + + '' + escHtml(fmtLabel) + '' + + '
' + + btnHtml + + '
'; + } + + if (deckListEl) { + 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', 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) { + 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; + 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": [