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: = json_encode($setsData) ?>,
subtypes: = json_encode($subtypesData) ?>,
formats: = json_encode($formatsData) ?>,
+ isLoggedIn: = $isLoggedIn ? 'true' : 'false' ?>,
+ csrf: = json_encode($isLoggedIn ? csrfToken() : '') ?>,
+ deckAddUrl: = json_encode(BASE_URL . '/papi/core-altered-cards/deck-add-card') ?>,
+ cardFactionCode: = json_encode($_cardFactionCode) ?>,
+ deckDefaultFaction: = json_encode($_deckDefaultFaction) ?>,
txt: = json_encode([
'not_found' => $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 ? '
' : '')
+ + ' ' + 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 ? '
 + ')
' : '
')
+ + '
'
+ + '
' + 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": [