From 409aba26f430a939c17ffbc016ff5a144eb36077 Mon Sep 17 00:00:00 2001 From: rgriffith Date: Sun, 5 Apr 2026 00:05:23 -0400 Subject: [PATCH] feat: AI bug fixes, deck/collection migration, and enrichment pipeline - Fix AI turn pipeline: add cmc to Scryfall cache, tag isRamp/isRemoval/ isBoardWipe from oracle text during enrichment, enable post-combat spell casting for all difficulty levels - Migrate deck builder, deck generator, and collection from ui/ to lan-client/ as iframe-embedded panels with LAB_API_BASE patching - Fix ai-bridge.js: turnNumber, deck_name, battlefield serialization, 5-playstyle mapping, window exports for cross-script access - Fix engine.js: battlefieldCards flat array (not player.battlefield), aiDeclareAttacks extraction, combat overlay fixes - Fix collection.js: template literal, column visibility alignment, set_code key, oracle text column, sortable category - Add exile/graveyard last-card image rendering in ui.js - Make state_encoder.py resilient to mixed input types - Enrich type_line from Scryfall at card insert in deckbuilder.py Co-Authored-By: Claude Opus 4.6 --- lan-client/ai-bridge.js | 23 +- lan-client/collection.css | 3234 ++++++++++++++++++++++++++++++++ lan-client/collection.html | 34 + lan-client/collection.js | 3197 ++++++++++++++++++++++++++++++++ lan-client/deckbuilder.css | 3286 +++++++++++++++++++++++++++++++++ lan-client/deckbuilder.html | 495 +++++ lan-client/deckbuilder.js | 2751 +++++++++++++++++++++++++++ lan-client/deckgenerator.css | 1402 ++++++++++++++ lan-client/deckgenerator.html | 231 +++ lan-client/deckgenerator.js | 792 ++++++++ lan-client/engine.js | 91 +- lan-client/global.css | 955 ++++++++++ lan-client/index.html | 115 ++ lan-client/precon-loader.js | 20 +- lan-client/style.css | 32 + lan-client/ui.js | 70 +- ml/encoder/state_encoder.py | 20 +- routes/deckbuilder.py | 14 + 18 files changed, 16726 insertions(+), 36 deletions(-) create mode 100644 lan-client/collection.css create mode 100644 lan-client/collection.html create mode 100644 lan-client/collection.js create mode 100644 lan-client/deckbuilder.css create mode 100644 lan-client/deckbuilder.html create mode 100644 lan-client/deckbuilder.js create mode 100644 lan-client/deckgenerator.css create mode 100644 lan-client/deckgenerator.html create mode 100644 lan-client/deckgenerator.js create mode 100644 lan-client/global.css diff --git a/lan-client/ai-bridge.js b/lan-client/ai-bridge.js index c42b684..e11d663 100644 --- a/lan-client/ai-bridge.js +++ b/lan-client/ai-bridge.js @@ -366,7 +366,11 @@ class GameStateBridge { seat_type: p.isAI ? this._mapAiProfile(gameState.aiDifficulty) : 'Human', - deck_name: p.commanderCardName || '', + deck_name: p.deckFileName || ( + p.commanderCardName + ? p.commanderCardName.replace(/[^a-zA-Z0-9]/g, '_') + '.dck' + : '' + ), player_name: p.name, })); @@ -394,11 +398,13 @@ class GameStateBridge { */ _mapAiProfile(difficulty) { const map = { - easy: 'AI', - normal: 'AI Aggro', - hard: 'AI Control', + easy: 'AI', + normal: 'AI Midrange', + hard: 'AI Control', + combo: 'AI Combo', + aggro: 'AI Aggro', }; - return map[difficulty] || 'AI'; + return map[difficulty] || 'AI Midrange'; } // ── Game State Serialization ────────────────────────────── @@ -451,7 +457,10 @@ class GameStateBridge { // Zone contents — card names hand: p.zones.hand.map(c => c.name || 'Unknown'), hand_count: p.zones.hand.length, - battlefield: bfCards.map(c => ({ + // Flat string list for the state encoder + battlefield: bfCards.map(c => c.name || 'Unknown'), + // Structured data for backend logic that needs detail + battlefield_detail: bfCards.map(c => ({ name: c.name || 'Unknown', type: c.typeLine || '', power: c.power || '0', @@ -488,7 +497,7 @@ class GameStateBridge { return { game_id: this._sessionGameId || '', - turn: gameState.turnCounter || 1, + turn: gameState.turnNumber || gameState.turnCounter || 1, phase: phaseMap[phaseId] || 'main_1', active_player: playerIdx, players: players, diff --git a/lan-client/collection.css b/lan-client/collection.css new file mode 100644 index 0000000..273082b --- /dev/null +++ b/lan-client/collection.css @@ -0,0 +1,3234 @@ +/* ── JS-referenced aliases (collection.js uses these in template literals) ── */ +:root { + --lab-purple: var(--purple); + --lab-warning: var(--warning); +} + +/* ═══════════════════════════════════════════════════════════ + Commander AI Lab — Collection Management Styles + Uses coll- prefix to avoid conflicts with lab- classes + Design tokens inherited from ai-lab.css :root variables + ═══════════════════════════════════════════════════════════ */ + +/* ── Z-INDEX STACK ──────────────────────────────────────── + 10 table thead (sticky) + 50 drawer header (sticky inside drawer) + 100 topbar (sticky) + 200 drawer backdrop + 250 drawer panel + 300 category popover + 1000 modal overlays (import/export) + ──────────────────────────────────────────────────────── */ + +/* ── 1. Page Layout ──────────────────────────────────────── */ + +#collection-page { + min-height: 100vh; + background: var(--bg); + padding: 0; + font-family: var(--font); + color: var(--text); + position: relative; + isolation: isolate; +} + +.coll-topbar { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + position: sticky; + top: var(--nav-height); + background: rgba(12, 14, 20, 0.95); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-bottom: 1px solid var(--border); + z-index: 100; + padding: 12px 24px; + gap: 12px; +} + +.coll-topbar-left { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.coll-topbar-right { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.coll-title { + font-size: 20px; + font-weight: 600; + color: #fff; + margin: 0; + line-height: 1; +} + +.coll-count { + color: var(--text-secondary); + font-size: 13px; +} + +.coll-back-btn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + padding: 6px 10px; + border-radius: var(--radius-md); + transition: color 0.15s; + display: flex; + align-items: center; + gap: 4px; +} + +.coll-back-btn:hover { + color: var(--accent); +} + +.coll-search { + width: 240px; + background: var(--surface-1); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 8px 14px; + font-size: 13px; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.coll-search::placeholder { + color: var(--text-secondary); +} + +.coll-search:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.coll-scan-btn, +.coll-import-btn, +.coll-export-btn, +.coll-autoclassify-btn, +.coll-reenrich-btn { + background: var(--surface-1); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 7px 14px; + font-size: 13px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.coll-export-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* Scan button has blue/accent styling */ +.coll-scan-btn { + background: linear-gradient(135deg, rgba(91, 158, 240, 0.12), rgba(91, 158, 240, 0.18)); + border-color: rgba(91, 158, 240, 0.4); + color: var(--accent); +} + +.coll-scan-btn:hover { + background: linear-gradient(135deg, rgba(91, 158, 240, 0.22), rgba(91, 158, 240, 0.28)); + border-color: var(--accent); + color: var(--accent-hover); +} + +/* Import button has green accent like lab-import-submit-btn */ +.coll-import-btn { + background: linear-gradient(135deg, rgba(35, 134, 54, 0.15), rgba(46, 160, 67, 0.15)); + border-color: rgba(46, 160, 67, 0.4); + color: var(--success); +} + +.coll-import-btn:hover { + background: linear-gradient(135deg, rgba(35, 134, 54, 0.25), rgba(46, 160, 67, 0.25)); + border-color: var(--success); + color: var(--success); +} + +/* Auto-Classify button — amber/orange accent */ +.coll-autoclassify-btn { + background: linear-gradient(135deg, rgba(227, 155, 35, 0.12), rgba(227, 155, 35, 0.18)); + border-color: rgba(227, 155, 35, 0.4); + color: var(--warning); +} + +.coll-autoclassify-btn:hover { + background: linear-gradient(135deg, rgba(227, 155, 35, 0.22), rgba(227, 155, 35, 0.28)); + border-color: var(--warning); + color: var(--gold); +} + +.coll-autoclassify-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.coll-reenrich-btn { + background: linear-gradient(135deg, rgba(136, 98, 235, 0.12), rgba(136, 98, 235, 0.18)); + border-color: rgba(136, 98, 235, 0.4); + color: var(--purple); +} + +.coll-reenrich-btn:hover { + background: linear-gradient(135deg, rgba(136, 98, 235, 0.22), rgba(136, 98, 235, 0.28)); + border-color: var(--purple); + color: var(--purple); +} + +.coll-reenrich-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* ── Nav Links ──────────────────────────────────────────── */ + +.coll-nav-link { + font-size: 13px; + text-decoration: none; + padding: 7px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--surface-1); + transition: border-color 0.15s, background 0.15s; + white-space: nowrap; + display: flex; + align-items: center; + gap: 6px; +} + +.coll-nav-link:hover { + background: rgba(91, 158, 240, 0.06); +} + +/* ── 2. Main Content ─────────────────────────────────────── */ + +.coll-main { + display: flex; + gap: 20px; + padding: 16px 24px; + align-items: flex-start; +} + +/* Table wrapper — fills remaining space */ +.coll-table-wrap { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + /* Constrain height so table scrolls independently of page */ + max-height: calc(100vh - var(--nav-height) - 80px); + overflow: hidden; +} + +/* ── 3. Filter Panel ─────────────────────────────────────── */ + +.coll-filters { + width: 240px; + flex-shrink: 0; + max-height: calc(100vh - var(--nav-height) - 80px); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.coll-filters::-webkit-scrollbar { + width: 6px; +} + +.coll-filters::-webkit-scrollbar-track { + background: transparent; +} + +.coll-filters::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +.coll-filters::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +.coll-filter-section { + margin-bottom: 16px; +} + +.coll-filter-label { + display: block; + text-transform: uppercase; + font-size: 11px; + color: var(--text-secondary); + letter-spacing: 0.5px; + margin-bottom: 8px; + font-weight: 600; +} + +/* Color toggles */ +.coll-color-btns { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.coll-color-btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: border-color 0.15s, transform 0.15s; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + line-height: 1; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + background: var(--surface-1); + color: var(--text-secondary); + padding: 0; +} + +.coll-color-btn:hover { + border-color: rgba(255, 255, 255, 0.5); + transform: scale(1.05); +} + +.coll-color-btn.active { + border-color: #fff; + transform: scale(1.1); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5); +} + +/* Type list checkboxes */ +.coll-type-checks { + display: flex; + flex-direction: column; + gap: 4px; +} + +.coll-check-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + cursor: pointer; + color: var(--text); + user-select: none; + padding: 2px 0; +} + +.coll-check-label input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg); + cursor: pointer; + flex-shrink: 0; + position: relative; + transition: border-color 0.15s, background 0.15s; +} + +.coll-check-label input[type="checkbox"]:checked { + background: var(--accent); + border-color: var(--accent); +} + +.coll-check-label input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + left: 3px; + top: 1px; + width: 5px; + height: 8px; + border: 2px solid #000; + border-top: none; + border-left: none; + transform: rotate(45deg); +} + +.coll-check-label:hover { + color: var(--text); +} + +.coll-check-label:hover input[type="checkbox"] { + border-color: var(--accent); +} + +/* Toggle buttons */ +.coll-toggle-btns { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.coll-toggle-btn { + padding: 5px 12px; + border-radius: 14px; + font-size: 11px; + cursor: pointer; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text-secondary); + transition: border-color 0.15s, color 0.15s, background 0.15s; + user-select: none; +} + +.coll-toggle-btn:hover { + border-color: var(--text-secondary); + color: var(--text); +} + +.coll-toggle-btn.active { + background: rgba(91, 158, 240, 0.15); + border-color: var(--accent); + color: var(--accent); +} + +/* Finish radios */ +.coll-finish-radios { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Range inputs */ +.coll-range-inputs { + display: flex; + gap: 8px; + align-items: center; +} + +.coll-range-sep { + color: var(--text-secondary); + font-size: 12px; +} + +.coll-range-input { + width: 70px; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 6px; + font-size: 12px; + text-align: center; + outline: none; + transition: border-color 0.15s; +} + +.coll-range-input:focus { + border-color: var(--accent); +} + +/* Category filter chips */ +.coll-cat-filter-btns { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.coll-cat-filter-btn { + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + cursor: pointer; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text-secondary); + transition: border-color 0.15s, color 0.15s, background 0.15s; + user-select: none; +} + +.coll-cat-filter-btn:hover { + border-color: var(--text-secondary); + color: var(--text); +} + +.coll-cat-filter-btn.active { + background: rgba(91, 158, 240, 0.15); + border-color: var(--accent); + color: var(--accent); +} + +/* Radio labels (shared by filters and import) */ +.coll-radio-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + cursor: pointer; + color: var(--text); + user-select: none; + padding: 3px 0; +} + +.coll-radio-label input[type="radio"] { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + border: 2px solid var(--border); + border-radius: 50%; + background: var(--bg); + cursor: pointer; + position: relative; + transition: border-color 0.15s; + flex-shrink: 0; +} + +.coll-radio-label input[type="radio"]:checked { + border-color: var(--accent); +} + +.coll-radio-label input[type="radio"]:checked::after { + content: ''; + position: absolute; + width: 6px; + height: 6px; + background: var(--accent); + border-radius: 50%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +/* Reset button */ +.coll-reset-btn { + width: 100%; + background: transparent; + border: 1px dashed var(--border); + border-radius: var(--radius-md); + padding: 7px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + transition: border-color 0.15s, color 0.15s; + margin-top: 4px; +} + +.coll-reset-btn:hover { + border-color: var(--error); + color: var(--error); +} + +/* ── 3b. New Filters ────────────────────────────────────────── */ + +/* Rarity buttons */ +.coll-rarity-btns { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.coll-rarity-btn { + width: 36px; + height: 30px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: transparent; + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; +} + +.coll-rarity-btn:hover { + border-color: var(--text-secondary); +} + +.coll-rarity-btn.active.coll-rarity-common { + background: var(--surface-0); + border-color: var(--text-secondary); + color: var(--text); +} + +.coll-rarity-btn.active.coll-rarity-uncommon { + background: var(--success-dim); + border-color: var(--success); + color: var(--success); +} + +.coll-rarity-btn.active.coll-rarity-rare { + background: var(--warning-dim); + border-color: var(--warning); + color: var(--gold); +} + +.coll-rarity-btn.active.coll-rarity-mythic { + background: var(--error-dim); + border-color: var(--error); + color: var(--error); +} + +/* Power / Toughness range row */ +.coll-range-row { + display: flex; + gap: 10px; +} + +.coll-range-group { + flex: 1; +} + +.coll-range-sublabel { + display: block; + font-size: 10px; + color: var(--text-secondary); + margin-bottom: 3px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Set filter autocomplete */ +.coll-set-filter, +.coll-kw-filter { + position: relative; +} + +.coll-set-input, +.coll-kw-input { + width: 100%; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 6px 8px; + font-size: 12px; + color: var(--text); + outline: none; + transition: border-color 0.15s; +} + +.coll-set-input:focus, +.coll-kw-input:focus { + border-color: var(--accent); +} + +.coll-set-dropdown, +.coll-kw-dropdown { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 180px; + overflow-y: auto; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 0 0 var(--radius-md) var(--radius-md); + z-index: 50; + box-shadow: 0 4px 12px rgba(0,0,0,.4); +} + +.coll-set-dd-item, +.coll-kw-dd-item { + padding: 6px 10px; + font-size: 12px; + color: var(--text); + cursor: pointer; + display: flex; + gap: 6px; + align-items: center; +} + +.coll-set-dd-item:hover, +.coll-kw-dd-item:hover { + background: rgba(91, 158, 240, 0.1); +} + +.coll-set-dd-code { + font-weight: 600; + color: var(--accent); + min-width: 40px; +} + +.coll-set-dd-name { + color: var(--text-secondary); +} + +/* Set/Keyword chips */ +.coll-set-chips, +.coll-kw-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; +} + +.coll-set-chip, +.coll-kw-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: rgba(91, 158, 240, 0.12); + border: 1px solid rgba(91, 158, 240, 0.25); + border-radius: 12px; + padding: 2px 8px; + font-size: 11px; + color: var(--accent); +} + +.coll-set-chip button, +.coll-kw-chip button { + background: none; + border: none; + color: var(--text-secondary); + font-size: 14px; + cursor: pointer; + padding: 0; + line-height: 1; +} + +.coll-set-chip button:hover, +.coll-kw-chip button:hover { + color: var(--error); +} + +/* ── 4. Table ─────────────────────────────────────────────── */ + +.coll-table-container { + flex: 1; + min-width: 0; + overflow-x: auto; + overflow-y: auto; +} + +.coll-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 13px; +} + +.coll-table thead { + position: sticky; + top: 0; + background: var(--surface-1); + z-index: 10; +} + +.coll-table th, +.coll-th { + padding: 10px 12px; + text-align: left; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); + border-bottom: 2px solid var(--border); + white-space: nowrap; + font-weight: 600; +} + +.coll-th-sortable { + cursor: pointer; + user-select: none; + transition: color 0.15s, background 0.15s; + position: relative; +} + +.coll-th-sortable:hover { + color: var(--accent); + background: rgba(255,255,255,0.06); +} + +.coll-th-active { + color: var(--accent); + background: rgba(255,255,255,0.04); +} + +.coll-table td, +.coll-td { + padding: 8px 12px; + border-bottom: 1px solid rgba(48, 54, 61, 0.5); + font-size: 13px; + color: var(--text); + vertical-align: middle; +} + +.coll-table tbody tr, +.coll-row { + cursor: pointer; + transition: background 0.1s; + animation: coll-fade-in 0.15s ease; +} + +.coll-table tbody tr:hover, +.coll-row:hover { + background: var(--surface-1); +} + +.coll-table tbody tr.selected { + background: rgba(91, 158, 240, 0.08); + box-shadow: inset 3px 0 0 var(--accent); +} + +/* Cell-specific styles */ +.coll-card-name, +.coll-td-name { + font-weight: 600; + color: #fff; + white-space: nowrap; +} + +.coll-td-type { + color: var(--text-secondary); + font-size: 12px; + white-space: nowrap; +} + +.coll-td-oracle { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); + font-size: 12px; +} + +.coll-td-price { + font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + text-align: right; + white-space: nowrap; +} + +.coll-td-salt { + font-weight: 600; + text-align: right; +} + +.coll-td-quantity { + text-align: center; + font-weight: 600; +} + +.coll-td-cmc { + text-align: center; +} + +.coll-td-gc { + text-align: center; +} + +.coll-td-mana { + white-space: nowrap; +} + +.coll-td-pt { + text-align: center; + white-space: nowrap; + font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + font-size: 12px; +} + +.coll-td-rarity { + text-align: center; + font-size: 12px; + text-transform: capitalize; +} + +.coll-td-set { + color: var(--text-secondary); + font-size: 12px; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.coll-td-edhrec { + text-align: right; + font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + font-size: 12px; + color: var(--text-secondary); +} + +/* Rarity colors */ +.coll-rarity-common { color: var(--text-secondary); } +.coll-rarity-uncommon { color: var(--accent); } +.coll-rarity-rare { color: var(--gold); } +.coll-rarity-mythic { color: var(--warning); } +.coll-rarity-special { color: var(--purple); } +.coll-rarity-bonus { color: var(--purple); } + +/* Salt color classes */ +.salt-green { color: var(--success); } +.salt-yellow { color: var(--warning); } +.salt-orange { color: var(--warning); } +.salt-red { color: var(--error); } + +/* Mana pips */ +.coll-mana-pip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + font-size: 9px; + font-weight: 700; + line-height: 1; + flex-shrink: 0; +} + +.coll-colorless-pip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + font-size: 9px; + font-weight: 700; + background: var(--text-tertiary); + color: #fff; +} + +/* Category chips in table cell */ +.coll-cat-chip { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 10px; + background: var(--surface-1); + border: 1px solid var(--border); + color: var(--text-secondary); + white-space: nowrap; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; + margin: 1px 2px; +} + +.coll-cat-chip:hover { + border-color: var(--accent); + color: var(--accent); +} + +.coll-no-category { + color: var(--text-secondary); + font-size: 12px; +} + +/* Finish badges */ +.coll-finish-badge { + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + white-space: nowrap; + display: inline-block; +} + +.finish-normal, +.coll-finish-normal { + background: rgba(139, 148, 158, 0.15); + color: var(--text-secondary); +} + +.finish-foil, +.coll-finish-foil { + background: linear-gradient(135deg, rgba(255, 215, 0, 0.15), rgba(163, 113, 247, 0.15)); + color: var(--gold); + border: 1px solid rgba(255, 215, 0, 0.3); +} + +.finish-etched, +.coll-finish-etched { + background: rgba(91, 158, 240, 0.15); + color: var(--accent); + border: 1px solid rgba(91, 158, 240, 0.3); +} + +/* Icons */ +.coll-gc-star { + color: var(--gold); + font-size: 14px; +} + +.coll-legendary-icon { + color: var(--gold); + font-size: 11px; + display: inline-block; +} + +.coll-basic-icon { + color: var(--text-secondary); + font-size: 11px; + display: inline-block; +} + +/* ── 5. Pagination ───────────────────────────────────────── */ + +.coll-pagination { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + border-top: 1px solid var(--border); + margin-top: 8px; + flex-wrap: wrap; + gap: 8px; +} + +.coll-page-info { + font-size: 13px; + color: var(--text-secondary); +} + +.coll-total-label { + color: var(--text-secondary); + font-size: 12px; + margin-left: 4px; +} + +.coll-page-controls { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.coll-page-btn { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 6px 14px; + font-size: 13px; + cursor: pointer; + color: var(--text); + transition: border-color 0.15s, color 0.15s; +} + +.coll-page-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.coll-page-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.coll-page-size { + font-size: 13px; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 6px; +} + +.coll-page-size select { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 4px 8px; + font-size: 12px; + color: var(--text); + cursor: pointer; + outline: none; +} + +.coll-page-size select:focus { + border-color: var(--accent); +} + +/* ── 6. Detail Drawer ────────────────────────────────────── */ + +.coll-drawer-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 200; + transition: opacity 0.25s ease; + opacity: 0; +} + +.coll-drawer-backdrop.open { + display: block; + opacity: 1; +} + +.coll-drawer { + position: fixed; + right: 0; + top: 0; + width: 420px; + height: 100vh; + height: 100dvh; + background: var(--bg); + border-left: 1px solid var(--border); + z-index: 250; + transform: translateX(100%); + transition: transform 0.25s ease; + overflow-y: auto; + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.5); + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.coll-drawer::-webkit-scrollbar { + width: 6px; +} + +.coll-drawer::-webkit-scrollbar-track { + background: transparent; +} + +.coll-drawer::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +.coll-drawer::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +.coll-drawer.open { + transform: translateX(0); +} + +.coll-drawer-inner { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.coll-drawer-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: var(--bg); + z-index: 50; +} + +.coll-drawer-header-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 6px; +} + +.coll-drawer-card-name { + font-size: 16px; + font-weight: 600; + color: #fff; + line-height: 1.3; +} + +.coll-drawer-close { + background: none; + border: none; + font-size: 18px; + color: var(--text-secondary); + cursor: pointer; + padding: 4px 6px; + border-radius: 4px; + transition: color 0.15s; + line-height: 1; + flex-shrink: 0; +} + +.coll-drawer-close:hover { + color: #fff; +} + +.coll-drawer-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + flex-wrap: wrap; +} + +.coll-drawer-type { + font-size: 12px; + color: var(--text-secondary); +} + +.coll-drawer-body { + padding: 20px; + flex: 1; +} + +.coll-drawer-image-wrap { + text-align: center; + margin-bottom: 16px; +} + +.coll-drawer-image { + max-width: 100%; + width: 280px; + border-radius: 12px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); +} + +.coll-drawer-no-image { + width: 280px; + height: 200px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: center; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 12px; + color: var(--text-secondary); + font-size: 14px; +} + +/* Loading/error states inside drawer */ +.coll-drawer-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px; + color: var(--text-secondary); +} + +.coll-drawer-error { + padding: 24px; + text-align: center; + color: var(--error); + font-size: 13px; +} + +/* Drawer Tabs */ +.coll-drawer-tabs { + display: flex; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; + margin-top: 8px; +} + +.coll-tab-btn { + padding: 10px 16px; + font-size: 13px; + cursor: pointer; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + background: none; + transition: color 0.15s, border-color 0.15s; + user-select: none; + margin-bottom: -1px; +} + +.coll-tab-btn:hover { + color: var(--text); +} + +.coll-tab-btn.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.coll-drawer-tab-content { + min-height: 200px; +} + +/* Overview tab */ +.coll-overview-grid { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 16px; +} + +.coll-overview-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + border-bottom: 1px solid rgba(48, 54, 61, 0.3); + font-size: 13px; +} + +.coll-ov-label { + font-size: 12px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.coll-oracle-text { + font-size: 13px; + color: var(--text); + padding: 12px; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + line-height: 1.5; + margin-bottom: 16px; +} + +/* Drawer sections */ +.coll-drawer-section { + margin-top: 16px; +} + +.coll-drawer-section-label { + display: block; + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + font-weight: 600; +} + +/* Finish select in drawer */ +.coll-finish-select { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 6px 10px; + font-size: 12px; + color: var(--text); + cursor: pointer; + outline: none; + width: 100%; + transition: border-color 0.15s; +} + +.coll-finish-select:focus { + border-color: var(--accent); +} + +/* Category chips in drawer */ +.coll-drawer-cat-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 8px; +} + +.coll-drawer-cat-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + background: rgba(91, 158, 240, 0.12); + border: 1px solid rgba(91, 158, 240, 0.3); + color: var(--accent); +} + +.coll-chip-remove { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 13px; + line-height: 1; + padding: 0 2px; + opacity: 0.6; + transition: opacity 0.15s; +} + +.coll-chip-remove:hover { + opacity: 1; +} + +.coll-cat-suggestions { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 8px; +} + +.coll-cat-suggest-btn { + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + background: var(--bg); + border: 1px dashed var(--border); + color: var(--text-secondary); + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.coll-cat-suggest-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.coll-cat-add-row, +.coll-tag-add-row { + display: flex; + gap: 6px; + margin-top: 6px; +} + +.coll-cat-input { + flex: 1; + min-width: 0; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 5px 10px; + font-size: 12px; + outline: none; + transition: border-color 0.15s; +} + +.coll-cat-input:focus { + border-color: var(--accent); +} + +.coll-cat-add-btn { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 5px 12px; + font-size: 12px; + color: var(--accent); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + white-space: nowrap; +} + +.coll-cat-add-btn:hover { + border-color: var(--accent); + background: rgba(91, 158, 240, 0.1); +} + +/* Tags in drawer */ +.coll-drawer-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 8px; +} + +.coll-drawer-tag-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + background: rgba(139, 148, 158, 0.12); + border: 1px solid rgba(139, 148, 158, 0.3); + color: var(--text); +} + +/* Notes textarea */ +.coll-notes-textarea { + width: 100%; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 10px; + font-size: 13px; + resize: vertical; + min-height: 80px; + font-family: inherit; + line-height: 1.5; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + box-sizing: border-box; +} + +.coll-notes-textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +/* Save row in drawer */ +.coll-drawer-save-row { + display: flex; + align-items: center; + gap: 12px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +.coll-save-btn { + background: linear-gradient(135deg, var(--success), var(--success)); + color: #fff; + font-weight: 600; + border: none; + border-radius: var(--radius-md); + padding: 8px 20px; + font-size: 13px; + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; +} + +.coll-save-btn:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(46, 160, 67, 0.3); +} + +.coll-save-status { + font-size: 12px; + color: var(--text-secondary); +} + +.coll-save-status.saved { + color: var(--success); +} + +.coll-save-status.error { + color: var(--error); +} + +/* EDHREC Tab */ +.coll-edhrec-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px; + color: var(--text-secondary); + font-size: 13px; +} + +.coll-edhrec-prompt { + text-align: center; + padding: 24px; + color: var(--text-secondary); + font-size: 13px; +} + +.coll-edhrec-fetch-btn { + background: var(--accent); + color: #000; + font-weight: 600; + border: none; + border-radius: var(--radius-md); + padding: 8px 20px; + font-size: 13px; + cursor: pointer; + margin-top: 8px; + transition: background 0.15s; +} + +.coll-edhrec-fetch-btn:hover { + background: var(--accent-hover); +} + +.coll-edhrec-recs { + display: flex; + flex-direction: column; + gap: 6px; +} + +.coll-edhrec-rec { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + gap: 8px; +} + +.coll-edhrec-rec:hover { + border-color: var(--accent); +} + +.coll-edhrec-rec-name { + font-size: 13px; + font-weight: 600; + color: #fff; +} + +.coll-edhrec-rec-meta { + display: flex; + gap: 8px; + align-items: center; + font-size: 11px; + flex-shrink: 0; +} + +.coll-edhrec-role { + color: var(--text-secondary); +} + +.coll-edhrec-synergy { + color: var(--success); + font-weight: 600; +} + +.coll-edhrec-inclusion { + color: var(--accent); + font-weight: 600; +} + +.coll-edhrec-empty { + text-align: center; + padding: 24px; + color: var(--text-secondary); +} + +.coll-edhrec-links { + display: flex; + gap: 8px; + margin-top: 12px; + flex-wrap: wrap; +} + +.coll-edhrec-link { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 6px 14px; + font-size: 12px; + color: var(--accent); + text-decoration: none; + transition: background 0.15s, border-color 0.15s; +} + +.coll-edhrec-link:hover { + background: rgba(91, 158, 240, 0.1); + border-color: var(--accent); +} + +/* Decks tab */ +.coll-decks-tab { + padding: 8px 0; +} + +.coll-decks-hint { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 12px; + line-height: 1.5; +} + +.coll-decks-links { + display: flex; + flex-direction: column; + gap: 8px; +} + +.coll-deck-link { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--accent); + text-decoration: none; + font-size: 13px; + transition: border-color 0.15s, background 0.15s; +} + +.coll-deck-link:hover { + border-color: var(--accent); + background: rgba(91, 158, 240, 0.08); +} + +/* ── 7. Modal Overlays (Import / Export) ─────────────────── */ + +.coll-modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 1000; + justify-content: center; + align-items: center; + padding: 20px; +} + +.coll-modal-overlay.active { + display: flex; + justify-content: center; + align-items: center; +} + +.coll-modal-content { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 12px; + width: 100%; + max-width: 500px; + max-height: 80vh; + overflow-y: auto; + padding: 24px; + animation: coll-slide-up 0.2s ease; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.coll-modal-content::-webkit-scrollbar { + width: 6px; +} + +.coll-modal-content::-webkit-scrollbar-track { + background: transparent; +} + +.coll-modal-content::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +.coll-modal-wide { + max-width: 640px; +} + +.coll-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.coll-modal-header h2, +.coll-modal-header h3 { + font-size: 16px; + font-weight: 600; + color: #fff; + margin: 0; +} + +.coll-modal-close { + background: none; + border: none; + font-size: 18px; + color: var(--text-secondary); + cursor: pointer; + padding: 4px 6px; + border-radius: 4px; + transition: color 0.15s; + line-height: 1; + flex-shrink: 0; +} + +.coll-modal-close:hover { + color: #fff; +} + +.coll-modal-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border); + gap: 8px; +} + +/* ── 8. Import Modal Internals ───────────────────────────── */ + +/* Step dots */ +.coll-import-steps { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + margin-bottom: 16px; +} + +.coll-step-dot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--surface-1); + border: 1px solid var(--border); + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.coll-step-dot.active { + background: var(--accent); + color: #000; + border-color: var(--accent); +} + +.coll-step-dot.done { + background: var(--success); + color: #000; + border-color: var(--success); +} + +.coll-step-sep { + color: var(--border); + font-size: 12px; + padding: 0 2px; +} + +/* Import step content */ +.coll-import-step-content { + min-height: 150px; +} + +/* Step 1: Source list */ +.coll-source-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.coll-source-option { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.coll-source-option:hover { + border-color: var(--accent); +} + +.coll-source-option.selected { + border-color: var(--accent); + background: rgba(91, 158, 240, 0.08); +} + +.coll-source-option input[type="radio"] { + flex-shrink: 0; +} + +.coll-source-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.coll-source-name { + font-size: 14px; + font-weight: 600; + color: #fff; +} + +.coll-source-desc { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.4; +} + +/* Step 2: Content / textarea */ +.coll-import-textarea-wrap, +.coll-import-file-wrap { + display: flex; + flex-direction: column; + gap: 8px; +} + +.coll-form-label { + font-size: 12px; + color: var(--text-secondary); + font-weight: 600; + display: block; +} + +.coll-import-textarea { + width: 100%; + background: var(--surface-1); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px; + font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + font-size: 12px; + line-height: 1.5; + resize: vertical; + min-height: 200px; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + box-sizing: border-box; +} + +.coll-import-textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.coll-import-hint { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.4; +} + +.coll-import-hint code { + background: var(--surface-1); + padding: 1px 4px; + border-radius: 3px; + font-size: 11px; +} + +/* File drop zone */ +.coll-file-drop-zone { + border: 2px dashed var(--border); + border-radius: var(--radius-md); + padding: 24px; + text-align: center; + cursor: pointer; + position: relative; + transition: border-color 0.15s, background 0.15s; +} + +.coll-file-drop-zone:hover, +.coll-file-drop-zone.drag-over { + border-color: var(--accent); + background: rgba(91, 158, 240, 0.05); +} + +.coll-file-drop-zone input[type="file"] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.coll-file-drop-label { + font-size: 13px; + color: var(--text-secondary); +} + +.coll-file-ok { + color: var(--success); + font-weight: 600; +} + +.coll-file-preview { + margin-top: 8px; +} + +.coll-file-preview-label { + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 4px; +} + +.coll-file-preview-text { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 8px; + font-size: 11px; + color: var(--text); + overflow-x: auto; + white-space: pre; + font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; + margin: 0; +} + +/* Step 3: Mapping */ +.coll-mapping-hint { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 12px; +} + +.coll-mapping-table { + width: 100%; + border-collapse: collapse; +} + +.coll-mapping-table th { + padding: 8px; + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + text-align: left; + border-bottom: 1px solid var(--border); + font-weight: 600; +} + +.coll-mapping-table td { + padding: 6px 8px; + vertical-align: middle; +} + +.coll-mapping-row td:first-child { + font-size: 13px; + font-weight: 600; + color: var(--text); + font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace; +} + +.coll-mapping-select { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 4px 8px; + font-size: 12px; + color: var(--text); + width: 100%; + outline: none; + transition: border-color 0.15s; + cursor: pointer; +} + +.coll-mapping-select:focus { + border-color: var(--accent); +} + +/* Step 4: Options */ +.coll-import-options { + display: flex; + flex-direction: column; + gap: 16px; +} + +.coll-form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.coll-mode-desc { + font-size: 11px; + color: var(--text-secondary); + display: block; + margin-top: 2px; +} + +.coll-import-warn { + font-size: 13px; + color: var(--warning); + padding: 10px 14px; + background: rgba(210, 153, 34, 0.08); + border: 1px solid rgba(210, 153, 34, 0.3); + border-radius: var(--radius-md); + line-height: 1.5; +} + +/* Import footer buttons */ +.coll-import-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border); + gap: 8px; +} + +.coll-btn-primary { + background: var(--accent); + color: #000; + font-weight: 600; + border: none; + border-radius: var(--radius-md); + padding: 8px 24px; + font-size: 13px; + cursor: pointer; + transition: background 0.15s, transform 0.15s; +} + +.coll-btn-primary:hover { + background: var(--accent-hover); + transform: translateY(-1px); +} + +.coll-btn-secondary { + background: none; + border: none; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + padding: 8px 16px; + border-radius: var(--radius-md); + transition: color 0.15s; +} + +.coll-btn-secondary:hover { + color: var(--text); +} + +/* Import result / status */ +.coll-import-status { + margin-top: 12px; +} + +.coll-import-progress { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-secondary); + font-size: 13px; + padding: 12px; +} + +.coll-import-result { + padding: 16px; + text-align: center; + border-radius: var(--radius-md); + font-size: 14px; + line-height: 1.6; + margin-bottom: 12px; +} + +.coll-import-result.success { + color: var(--success); + background: rgba(46, 160, 67, 0.08); + border: 1px solid rgba(46, 160, 67, 0.3); +} + +.coll-import-result.error { + color: var(--error); + background: rgba(248, 81, 73, 0.08); + border: 1px solid rgba(248, 81, 73, 0.3); +} + +.coll-import-errors { + text-align: left; + margin-top: 12px; + padding: 10px; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + font-size: 12px; + color: var(--text-secondary); +} + +.coll-import-errors-label { + font-weight: 600; + margin-bottom: 6px; + color: var(--warning); +} + +.coll-import-errors ul { + list-style: disc; + margin: 0; + padding-left: 16px; +} + +.coll-import-errors li { + padding: 2px 0; +} + +/* ── 9. Export Modal Internals ───────────────────────────── */ + +.coll-export-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.coll-export-select { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 8px 12px; + font-size: 13px; + color: var(--text); + cursor: pointer; + outline: none; + width: 100%; + transition: border-color 0.15s; +} + +.coll-export-select:focus { + border-color: var(--accent); +} + +.coll-export-info { + font-size: 12px; + color: var(--text-secondary); +} + +/* ── 10. Category Popover ────────────────────────────────── */ + +#coll-cat-popover { + position: absolute; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px; + z-index: 300; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + min-width: 220px; + max-width: 320px; +} + +.coll-popover-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 12px; + font-weight: 600; + color: var(--text); +} + +.coll-popover-close { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + padding: 2px 4px; + line-height: 1; + transition: color 0.15s; +} + +.coll-popover-close:hover { + color: #fff; +} + +.coll-popover-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 6px; +} + +.coll-popover-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 12px; + font-size: 11px; + background: rgba(91, 158, 240, 0.12); + border: 1px solid rgba(91, 158, 240, 0.3); + color: var(--accent); +} + +.coll-popover-suggestions { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 6px; +} + +.coll-popover-suggest-btn { + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + background: var(--bg); + border: 1px dashed var(--border); + color: var(--text-secondary); + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.coll-popover-suggest-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.coll-popover-add-row { + display: flex; + gap: 6px; + margin-top: 4px; +} + +/* ── 11. Loading / Spinner / Empty ───────────────────────── */ + +#coll-loading { + position: fixed; + inset: 0; + background: rgba(12, 14, 20, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; +} + +.coll-spinner { + display: inline-block; + width: 32px; + height: 32px; + border: 3px solid var(--border); + border-top: 3px solid var(--accent); + border-radius: 50%; + animation: coll-spin 0.8s linear infinite; +} + +.coll-spinner-inline { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--border); + border-top: 2px solid var(--accent); + border-radius: 50%; + animation: coll-spin 0.8s linear infinite; + vertical-align: middle; +} + +.coll-empty { + text-align: center; + padding: 32px; + color: var(--text-secondary); +} + +.coll-empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 24px; +} + +.coll-empty-icon { + font-size: 48px; +} + +/* ── 12. Responsive ──────────────────────────────────────── */ + +@media (max-width: 1024px) { + .coll-filters { + width: 200px; + } + + .coll-drawer { + width: 360px; + } +} + +@media (max-width: 768px) { + .coll-main { + flex-direction: column; + } + + .coll-filters { + width: 100%; + flex-direction: row; + flex-wrap: wrap; + overflow-x: auto; + position: static; + max-height: none; + display: flex; + gap: 12px; + } + + .coll-filter-section { + min-width: 180px; + margin-bottom: 0; + } + + .coll-drawer { + width: 100%; + } + + .coll-topbar { + flex-wrap: wrap; + gap: 8px; + padding: 10px 16px; + } + + .coll-search { + width: 100%; + order: 3; + } + + .coll-table-container { + overflow-x: auto; + } + + .coll-source-list { + flex-direction: column; + } +} + +/* ── 13. Scrollbar Styling (global for collection page) ──── */ + +#collection-page ::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +#collection-page ::-webkit-scrollbar-track { + background: transparent; +} + +#collection-page ::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +#collection-page ::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* ── 14. Card Scanner Modal ────────────────────────────── */ + +.coll-scan-content { + padding: 0 4px; +} + +.coll-scan-upload-area { + display: flex; + flex-direction: column; + gap: 16px; + padding: 8px 0; +} + +.coll-scan-dropzone { + border: 2px dashed var(--border); + border-radius: 8px; + min-height: 200px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; + overflow: hidden; + position: relative; +} + +.coll-scan-dropzone:hover, +.coll-scan-dropzone.drag-over { + border-color: var(--accent); + background: rgba(91, 158, 240, 0.04); +} + +.coll-scan-dropzone-text { + color: var(--text-secondary); + font-size: 14px; + text-align: center; + padding: 40px 20px; +} + +.coll-scan-preview-img { + max-width: 100%; + max-height: 360px; + object-fit: contain; + border-radius: 4px; +} + +.coll-scan-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.coll-scan-mode { + display: flex; + gap: 20px; +} + +.coll-radio-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text); + cursor: pointer; +} + +.coll-radio-label input[type="radio"] { + accent-color: var(--accent); +} + +.coll-scan-hint { + font-size: 12px; + color: var(--text-secondary); +} + +.coll-scan-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px 0 4px; + border-top: 1px solid var(--border); + margin-top: 12px; +} + +.coll-scan-footer .coll-btn-primary.scanning { + opacity: 0.7; + pointer-events: none; +} + +/* Scan Results */ + +.coll-scan-results-wrap { + padding: 4px 0; +} + +.coll-scan-results-header { + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 10px; + font-size: 13px; + color: var(--text-secondary); +} + +.coll-btn-link { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + font-size: 12px; + padding: 2px 4px; + text-decoration: underline; + text-decoration-color: transparent; + transition: text-decoration-color 0.15s; +} + +.coll-btn-link:hover { + text-decoration-color: var(--accent); +} + +.coll-scan-results-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 420px; + overflow-y: auto; + padding-right: 4px; +} + +.coll-scan-result-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: 6px; + transition: border-color 0.15s; +} + +.coll-scan-result-row:hover { + border-color: var(--text-secondary); +} + +.coll-scan-result-row.coll-scan-error { + border-color: rgba(248, 81, 73, 0.3); + background: rgba(248, 81, 73, 0.06); +} + +.coll-scan-result-check { + flex-shrink: 0; +} + +.coll-scan-result-check input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} + +.coll-scan-result-img { + width: 56px; + height: 78px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; + background: var(--bg); +} + +.coll-scan-result-img-placeholder { + width: 56px; + height: 78px; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + color: var(--text-secondary); + background: var(--bg); + border-radius: 4px; + flex-shrink: 0; +} + +.coll-scan-result-info { + flex: 1; + min-width: 0; +} + +.coll-scan-result-name { + font-size: 14px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.coll-scan-result-meta { + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; +} + +.coll-scan-result-err { + font-size: 12px; + color: var(--lab-danger, var(--error)); +} + +.coll-scan-conf-high { color: var(--lab-success, var(--success)); font-weight: 500; } +.coll-scan-conf-medium { color: var(--lab-warning, var(--warning)); font-weight: 500; } +.coll-scan-conf-low { color: var(--lab-danger, var(--error)); font-weight: 500; } + +.coll-scan-result-qty { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.coll-scan-result-qty label { + font-size: 10px; + color: var(--text-secondary); + text-transform: uppercase; +} + +.coll-scan-qty-input { + width: 48px; + padding: 4px 6px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 13px; + text-align: center; +} + +.coll-scan-qty-input:focus { + outline: none; + border-color: var(--accent); +} + +/* Scan status messages */ +.coll-scan-status { + padding: 0 4px; +} + +.coll-scan-progress { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-secondary); + font-size: 13px; + padding: 8px 0; +} + +.coll-scan-success { + color: var(--lab-success, var(--success)); + font-size: 13px; + padding: 8px 0; + font-weight: 500; +} + +.coll-scan-error-msg { + color: var(--lab-danger, var(--error)); + font-size: 13px; + padding: 8px 0; +} + +/* Manual name entry */ + +.coll-scan-result-err-wrap { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; +} + +.coll-scan-manual-row { + display: flex; + gap: 8px; + align-items: center; +} + +.coll-scan-manual-input { + flex: 1; + padding: 6px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 13px; +} + +.coll-scan-manual-input:focus { + outline: none; + border-color: var(--accent); +} + +.coll-scan-manual-input::placeholder { + color: var(--text-secondary); +} + +.coll-btn-sm { + padding: 5px 12px; + font-size: 12px; + background: var(--surface-1); + color: var(--accent); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; + white-space: nowrap; + transition: border-color 0.15s, background 0.15s; +} + +.coll-btn-sm:hover { + border-color: var(--accent); + background: rgba(91, 158, 240, 0.08); +} + +.coll-scan-manual-add { + display: flex; + gap: 8px; + align-items: center; + padding: 12px 0 4px; + border-top: 1px solid var(--border); + margin-top: 8px; +} + +.coll-scan-manual-input-bottom { + min-width: 0; +} + +.coll-scan-result-row.coll-scan-manual-added { + border-color: rgba(63, 185, 80, 0.3); + background: rgba(63, 185, 80, 0.04); +} + +.coll-scan-remove-manual { + flex-shrink: 0; + padding: 4px 8px; + color: var(--text-secondary); + border-color: transparent; + background: transparent; +} + +.coll-scan-remove-manual:hover { + color: var(--lab-danger, var(--error)); +} + +/* ── 15. Animations ──────────────────────────────────────── */ + +/* ── Sort Bar ─────────────────────────────────────────── */ + +.coll-sort-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + margin-bottom: 8px; + background: var(--card-bg, #1e1e2e); + border: 1px solid var(--border, #2a2a3a); + border-radius: 8px; + font-size: 13px; + color: var(--text-secondary, #999); +} + +.coll-sort-bar-label { + font-weight: 600; + color: var(--text-secondary, #999); + white-space: nowrap; +} + +.coll-sort-select { + background: var(--bg, #121220); + color: var(--text, #e0e0e0); + border: 1px solid var(--border, #2a2a3a); + border-radius: 6px; + padding: 5px 10px; + font-size: 13px; + cursor: pointer; + transition: border-color 0.15s; +} + +.coll-sort-select:hover, +.coll-sort-select:focus { + border-color: var(--accent, #6c63ff); + outline: none; +} + +.coll-sort-dir-btn { + background: var(--bg, #121220); + color: var(--text, #e0e0e0); + border: 1px solid var(--border, #2a2a3a); + border-radius: 6px; + padding: 5px 12px; + font-size: 13px; + cursor: pointer; + font-weight: 600; + transition: border-color 0.15s, background 0.15s; + white-space: nowrap; +} + +.coll-sort-dir-btn:hover { + border-color: var(--accent, #6c63ff); + background: rgba(108, 99, 255, 0.1); +} + +.coll-sort-bar-count { + margin-left: auto; + font-weight: 500; + color: var(--text-secondary, #999); + white-space: nowrap; +} + +@keyframes coll-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes coll-slide-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes coll-spin { + to { transform: rotate(360deg); } +} + +/* -- 16. Card Image Hover Preview -------------------------------- */ +#coll-card-preview { + position: fixed; + pointer-events: none; + z-index: 1100; + display: none; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7); + overflow: hidden; +} + +#coll-card-preview-img { + display: block; + width: 220px; + height: auto; + border-radius: 12px; +} + +/* ── 17. Stats Dashboard ─────────────────────────────────────── */ + +.coll-stats-btn { + background: rgba(99,102,241,0.15); + border: 1px solid rgba(99,102,241,0.4); + color: var(--accent, #6366f1); + border-radius: 6px; + padding: 6px 12px; + font-size: 0.8rem; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, border-color 0.15s; +} +.coll-stats-btn:hover { + background: rgba(99,102,241,0.3); + border-color: var(--accent, #6366f1); +} + +.coll-stats-panel { + background: var(--surface, #161b22); + border: 1px solid var(--border, #30363d); + border-radius: 10px; + padding: 20px 24px; + margin: 0 0 12px 0; + animation: coll-fade-in 0.2s ease; +} + +.coll-stats-loading { + color: var(--text-secondary, #8b949e); + padding: 16px; + text-align: center; +} + +.coll-stats-grid { + display: grid; + grid-template-columns: 1fr; + gap: 20px; +} + +/* Summary KPI cards */ +.coll-stats-summary { + display: flex; + gap: 12px; + flex-wrap: wrap; +} +.coll-stat-card { + flex: 1; + min-width: 100px; + background: var(--bg, #0d1117); + border: 1px solid var(--border, #30363d); + border-radius: 8px; + padding: 12px 16px; + text-align: center; +} +.coll-stat-num { + font-size: 1.5rem; + font-weight: 700; + color: var(--text, #e6edf3); + line-height: 1.2; +} +.coll-stat-label { + font-size: 0.72rem; + color: var(--text-secondary, #8b949e); + margin-top: 4px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +/* Section headers */ +.coll-stats-section { + border-top: 1px solid var(--border, #30363d); + padding-top: 14px; +} +.coll-stats-section h4 { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-secondary, #8b949e); + text-transform: uppercase; + letter-spacing: 0.06em; + margin: 0 0 10px 0; +} + +/* CMC vertical bar chart */ +.coll-stat-bar-chart { + display: flex; + align-items: flex-end; + gap: 4px; + height: 64px; + padding-bottom: 20px; + position: relative; +} +.coll-stat-bar-col { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + height: 100%; + justify-content: flex-end; + position: relative; +} +.coll-stat-bar { + width: 100%; + background: var(--accent, #6366f1); + border-radius: 3px 3px 0 0; + min-height: 2px; + transition: height 0.3s ease; +} +.coll-stat-bar-label { + position: absolute; + bottom: -18px; + font-size: 0.68rem; + color: var(--text-secondary, #8b949e); +} + +/* Horizontal bar rows (colors, types, rarity, categories) */ +.coll-stat-color-bar, +.coll-stat-type-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 5px; +} +.coll-stat-color-pip { + width: 22px; + height: 22px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 700; + flex-shrink: 0; +} +.coll-stat-type-label { + width: 90px; + font-size: 0.75rem; + color: var(--text, #e6edf3); + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.coll-stat-hbar-track { + flex: 1; + height: 10px; + background: var(--bg, #0d1117); + border-radius: 5px; + overflow: hidden; + border: 1px solid var(--border, #30363d); +} +.coll-stat-hbar { + height: 100%; + border-radius: 5px; + transition: width 0.4s ease; +} +.coll-stat-hbar-type { + background: var(--accent, #6366f1); +} +.coll-stat-hbar-cat { + background: #2ea043; +} +.coll-stat-hbar-val { + width: 36px; + font-size: 0.72rem; + color: var(--text-secondary, #8b949e); + text-align: right; + flex-shrink: 0; +} + +/* Top value cards list */ +.coll-stat-top-row { + display: flex; + justify-content: space-between; + font-size: 0.78rem; + padding: 3px 0; + border-bottom: 1px solid var(--border, #30363d); + gap: 8px; +} +.coll-stat-top-row span:first-child { + color: var(--text, #e6edf3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.coll-stat-top-row span:last-child { + color: #3fb950; + font-weight: 600; + flex-shrink: 0; +} + +.coll-stats-empty { + color: var(--text-secondary, #8b949e); + font-size: 0.78rem; + font-style: italic; +} + +@media (min-width: 900px) { + .coll-stats-grid { + grid-template-columns: repeat(2, 1fr); + } + .coll-stats-summary { + grid-column: 1 / -1; + } +} +@media (min-width: 1400px) { + .coll-stats-grid { + grid-template-columns: repeat(3, 1fr); + } +} + + + +/* ── 18. Column Visibility Panel ───────────────────────────── */ + +.coll-col-toggle-btn { + background: rgba(139,148,158,0.1); + border: 1px solid var(--border, #30363d); + color: var(--text-secondary, #8b949e); + border-radius: 6px; + padding: 6px 12px; + font-size: 0.8rem; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; +} +.coll-col-toggle-btn:hover { + background: rgba(139,148,158,0.2); + color: var(--text, #e6edf3); +} + +.coll-col-panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 500; + background: var(--surface, #161b22); + border: 1px solid var(--border, #30363d); + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); + min-width: 180px; + animation: coll-fade-in 0.15s ease; +} + +.coll-col-panel-inner { + padding: 10px 12px 12px; +} + +.coll-col-panel-hdr { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.78rem; + font-weight: 600; + color: var(--text-secondary, #8b949e); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border, #30363d); +} + +.coll-col-close { + background: none; + border: none; + color: var(--text-secondary, #8b949e); + cursor: pointer; + font-size: 1rem; + padding: 0 4px; + line-height: 1; +} +.coll-col-close:hover { color: var(--text, #e6edf3); } + +.coll-col-toggle-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + font-size: 0.8rem; + color: var(--text, #e6edf3); + cursor: pointer; + user-select: none; +} +.coll-col-toggle-item input[type="checkbox"] { + accent-color: var(--accent, #6366f1); + cursor: pointer; +} +.coll-col-toggle-item:hover { + color: var(--accent, #6366f1); +} + +.coll-col-reset { + margin-top: 10px; + width: 100%; + background: rgba(99,102,241,0.1); + border: 1px solid rgba(99,102,241,0.3); + color: var(--accent, #6366f1); + border-radius: 5px; + padding: 5px 8px; + font-size: 0.75rem; + cursor: pointer; + transition: background 0.15s; +} +.coll-col-reset:hover { + background: rgba(99,102,241,0.25); +} + + +/* ── Bulk Action Bar ──────────────────────────────────────────── */ +.coll-bulk-bar { + position: sticky; + bottom: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--bg-card, #1e1e1e); + border-top: 1px solid var(--border, #30363d); + border-radius: 0 0 8px 8px; + z-index: 50; + flex-wrap: wrap; + box-shadow: 0 -2px 8px rgba(0,0,0,0.3); +} +.coll-bulk-count { + font-size: 0.85rem; + font-weight: 600; + color: var(--accent, #6366f1); + min-width: 80px; +} +.coll-bulk-sep { + color: var(--text-secondary, #8b949e); + padding: 0 2px; +} +.coll-bulk-label { + font-size: 0.82rem; + color: var(--text-secondary, #8b949e); +} +.coll-bulk-btn { + background: rgba(99,102,241,0.15); + border: 1px solid rgba(99,102,241,0.35); + color: var(--text, #e6edf3); + border-radius: 5px; + padding: 4px 10px; + font-size: 0.82rem; + cursor: pointer; + transition: background 0.15s; +} +.coll-bulk-btn:hover:not(:disabled) { + background: rgba(99,102,241,0.3); +} +.coll-bulk-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.coll-bulk-set-btn { + background: rgba(99,102,241,0.25); + font-weight: 600; +} +.coll-bulk-qty-input { + width: 56px; + background: var(--bg, #0d1117); + border: 1px solid var(--border, #30363d); + color: var(--text, #e6edf3); + border-radius: 5px; + padding: 3px 6px; + font-size: 0.82rem; + text-align: center; +} + +/* ── Select Checkbox Column ─────────────────────────────────── */ +.coll-th-select { + width: 32px; + min-width: 32px; + text-align: center; + padding: 4px; +} +.coll-td-select { + width: 32px; + min-width: 32px; + text-align: center; + padding: 4px; +} +.coll-row-check { + cursor: pointer; + width: 14px; + height: 14px; + accent-color: var(--accent, #6366f1); +} +.coll-row.selected-row { + background: rgba(99,102,241,0.08); +} \ No newline at end of file diff --git a/lan-client/collection.html b/lan-client/collection.html new file mode 100644 index 0000000..81269b9 --- /dev/null +++ b/lan-client/collection.html @@ -0,0 +1,34 @@ + + + + + + Commander AI Lab — Collection + + + + + + + + + +
+ + + + + + + diff --git a/lan-client/collection.js b/lan-client/collection.js new file mode 100644 index 0000000..135e80d --- /dev/null +++ b/lan-client/collection.js @@ -0,0 +1,3197 @@ +/** + * Commander AI Lab — Collection Management UI + * ═══════════════════════════════════════════ + * + * Endpoints consumed: + * GET /api/collection — list with filters/pagination/sort + * GET /api/collection/{id} — card detail + * PATCH /api/collection/{id} — update card (category, tags, notes, finish) + * POST /api/collection/import — import cards + * GET /api/collection/export — export cards (file download) + * GET /api/collection/{id}/edhrec — EDHREC recommendations + */ + +const Collection = (() => { + 'use strict'; + + // ── Configuration ────────────────────────────────────── + const API_BASE = window.LAB_API_BASE || window.location.origin; + const LS_PREFS_KEY = 'coll_preferences'; + const DEBOUNCE_MS = 300; + + // ── Card Preview (hover tooltip with delay) ──────────── + const CollCardPreview = { + el: null, + img: null, + visible: false, + _hoverTimer: null, + HOVER_DELAY: 225, + init() { + this.el = document.getElementById('coll-card-preview'); + this.img = document.getElementById('coll-card-preview-img'); + }, + show(imgUrl, mx, my) { + if (!imgUrl || !this.el) return; + this.img.src = imgUrl; + this.img.onerror = () => this.hide(); + const vw = window.innerWidth, vh = window.innerHeight, w = 220; + let x = mx + 16, y = my - 20; + if (x + w > vw - 10) x = mx - w - 16; + if (y + 310 > vh - 10) y = vh - 320; + if (y < 10) y = 10; + this.el.style.left = `${x}px`; + this.el.style.top = `${y}px`; + this.el.style.display = 'block'; + this.visible = true; + }, + hide() { + clearTimeout(this._hoverTimer); + if (this.el) this.el.style.display = 'none'; + this.visible = false; + }, + attachAll() { + if (!this.el) this.init(); + document.querySelectorAll('.coll-row[data-img-url]').forEach(row => { + const imgUrl = row.dataset.imgUrl; + const nameEl = row.querySelector('.coll-td-name'); + if (!imgUrl || !nameEl || nameEl._previewBound) return; + nameEl._previewBound = true; + let lastE = null; + nameEl.addEventListener('mouseenter', (e) => { + lastE = e; + clearTimeout(this._hoverTimer); + this._hoverTimer = setTimeout(() => { + if (lastE) this.show(imgUrl, lastE.clientX, lastE.clientY); + }, this.HOVER_DELAY); + }); + nameEl.addEventListener('mousemove', (e) => { + lastE = e; + if (this.visible) this.show(imgUrl, e.clientX, e.clientY); + }); + nameEl.addEventListener('mouseleave', () => this.hide()); + }); + }, + }; + const COLOR_MAP = { + W: { label: 'W', bg: '#f9faf4', color: '#3d3929' }, + U: { label: 'U', bg: '#0e67ab', color: '#fff' }, + B: { label: 'B', bg: '#2b2424', color: '#ccc' }, + R: { label: 'R', bg: '#d3202a', color: '#fff' }, + G: { label: 'G', bg: '#00733e', color: '#fff' }, + C: { label: 'C', bg: '#706f6f', color: '#fff' }, + }; + + const CARD_TYPES = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land']; + const CATEGORY_SUGGESTIONS = ['Ramp', 'Draw', 'Removal', 'Board Wipe', 'Anthem', 'Protection', 'Tutor', 'Counter', 'Token', 'Sacrifice', 'Recursion', 'Graveyard', 'Lifegain', 'Burn', 'Stax', 'Evasion', 'Finisher', 'Combo']; + + const FIELD_OPTIONS = [ + { value: 'IGNORE', label: '— Ignore —' }, + { value: 'QUANTITY', label: 'Quantity' }, + { value: 'NAME', label: 'Name' }, + { value: 'SET_CODE', label: 'Set Code' }, + { value: 'SET_CODE_SECONDARY', label: 'Set Code (secondary)' }, + { value: 'COLLECTOR_NUMBER', label: 'Collector Number' }, + { value: 'COLLECTOR_NUMBER_SECONDARY',label: 'Collector Number (secondary)' }, + { value: 'FINISH', label: 'Finish' }, + { value: 'CONDITION', label: 'Condition' }, + { value: 'LANGUAGE', label: 'Language' }, + ]; + + const MOXFIELD_DEFAULTS = { + 'Count': 'QUANTITY', + 'Name': 'NAME', + 'Edition': 'SET_CODE', + 'Foil': 'FINISH', + 'Collector Number': 'COLLECTOR_NUMBER', + 'Condition': 'CONDITION', + 'Language': 'LANGUAGE', + }; + + const ARCHIDEKT_DEFAULTS = { + 'Quantity': 'QUANTITY', + 'Name': 'NAME', + 'Set Code': 'SET_CODE', + 'Collector Number': 'COLLECTOR_NUMBER', + }; + + // ── State ─────────────────────────────────────────────── + let state = { + items: [], + page: 1, + pageSize: 50, + total: 0, + loading: false, + search: '', + sortField: 'name', + sortDir: 'asc', + filters: { + colors: [], + types: [], + isLegendary: false, + isBasic: false, + isGameChanger: false, + highSalt: false, + finish: '', + cmcMin: null, + cmcMax: null, + priceMin: null, + priceMax: null, + category: [], + rarity: [], + setCode: [], + powerMin: null, + powerMax: null, + toughMin: null, + toughMax: null, + keyword: [], + edhrecMin: null, + edhrecMax: null, + qtyMin: null, + qtyMax: null, + }, + cachedSets: [], + cachedKeywords: [], + selectedCard: null, + drawerOpen: false, + drawerTab: 'overview', + edhrecData: null, + edhrecLoading: false, + importStep: 0, + importSource: '', + importContent: '', + importMapping: {}, + importMode: 'MERGE', + importHeaders: [], + importMissingFinishNormal: true, + exportOpen: false, + selectedRows: new Set(), + preferences: { + pageSize: 50, + sortField: 'name', + sortDir: 'asc', + visibleColumns: ['quantity','name','mana_cost','type_line','color_identity','cmc','power_toughness','rarity','set_code','tcg_price','edhrec_rank','salt_score','category','finish','oracle_text'], + }, + }; + + let searchDebounceTimer = null; + + // ── Utilities ─────────────────────────────────────────── + + function escapeHtml(str) { + if (str == null) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function escapeAttr(str) { + return escapeHtml(str); + } + + function formatPrice(price) { + if (price == null || isNaN(price)) return '—'; + return `$${Number(price).toFixed(2)}`; + } + + function saltClass(score) { + if (score == null) return ''; + if (score < 1) return 'salt-green'; + if (score < 2) return 'salt-yellow'; + if (score < 3) return 'salt-orange'; + return 'salt-red'; + } + + function truncate(str, len) { + if (!str) return ''; + return str.length > len ? str.slice(0, len) + '…' : str; + } + + function renderManaCost(manaCost) { + if (!manaCost) return '—'; + // Parse mana symbols like {1}{U}{B}, {X}, {W/U}, etc. + const symbols = manaCost.match(/\{[^}]+\}/g); + if (!symbols) return escapeHtml(manaCost); + return symbols.map(sym => { + const inner = sym.slice(1, -1); // strip { } + const ci = COLOR_MAP[inner]; + if (ci) { + return `${ci.label}`; + } + // Generic/colorless/X/hybrid + return `${escapeHtml(inner)}`; + }).join(''); + } + + function renderColorPips(colorIdentity) { + if (!colorIdentity || colorIdentity.length === 0) return 'C'; + return colorIdentity.map(c => { + const ci = COLOR_MAP[c]; + if (!ci) return ''; + return `${ci.label}`; + }).join(''); + } + + function renderFinishBadge(finish) { + if (!finish || finish === 'NORMAL') return 'Normal'; + if (finish === 'FOIL') return 'Foil'; + if (finish === 'ETCHED') return 'Etched'; + return `${escapeHtml(finish)}`; + } + + function renderCategoryChips(categories, cardId) { + if (!categories || categories.length === 0) return '—'; + return categories.map(cat => + `${escapeHtml(cat)}` + ).join(''); + } + + function showLoading(show) { + const spinner = document.getElementById('coll-loading'); + if (spinner) spinner.style.display = show ? 'flex' : 'none'; + } + + // ── Preferences ───────────────────────────────────────── + + function loadPreferences() { + try { + const saved = localStorage.getItem(LS_PREFS_KEY); + if (saved) { + const parsed = JSON.parse(saved); + state.preferences = { ...state.preferences, ...parsed }; + state.pageSize = state.preferences.pageSize; + state.sortField = state.preferences.sortField; + state.sortDir = state.preferences.sortDir; + } + } catch (e) { + console.warn('[Collection] Failed to load preferences:', e); + } + } + + function savePreferences() { + try { + state.preferences.pageSize = state.pageSize; + state.preferences.sortField = state.sortField; + state.preferences.sortDir = state.sortDir; + localStorage.setItem(LS_PREFS_KEY, JSON.stringify(state.preferences)); + } catch (e) { + console.warn('[Collection] Failed to save preferences:', e); + } + } + + // ── URL Sync ──────────────────────────────────────────── + + function syncStateFromUrl() { + const params = new URLSearchParams(window.location.search); + if (params.has('q')) state.search = params.get('q'); + if (params.has('page')) state.page = parseInt(params.get('page'), 10) || 1; + if (params.has('pageSize')) state.pageSize = parseInt(params.get('pageSize'), 10) || 50; + if (params.has('sortField')) state.sortField = params.get('sortField'); + if (params.has('sortDir')) state.sortDir = params.get('sortDir'); + if (params.has('colors')) state.filters.colors = params.get('colors').split(',').filter(Boolean); + if (params.has('types')) state.filters.types = params.get('types').split(',').filter(Boolean); + if (params.has('finish')) state.filters.finish = params.get('finish'); + if (params.has('category')) state.filters.category = params.get('category').split(',').filter(Boolean); + if (params.has('isLegendary')) state.filters.isLegendary = params.get('isLegendary') === 'true'; + if (params.has('isBasic')) state.filters.isBasic = params.get('isBasic') === 'true'; + if (params.has('isGameChanger')) state.filters.isGameChanger = params.get('isGameChanger') === 'true'; + if (params.has('highSalt')) state.filters.highSalt = params.get('highSalt') === 'true'; + if (params.has('cmcMin')) state.filters.cmcMin = parseFloat(params.get('cmcMin')); + if (params.has('cmcMax')) state.filters.cmcMax = parseFloat(params.get('cmcMax')); + if (params.has('priceMin')) state.filters.priceMin = parseFloat(params.get('priceMin')); + if (params.has('priceMax')) state.filters.priceMax = parseFloat(params.get('priceMax')); + if (params.has('rarity')) state.filters.rarity = params.get('rarity').split(',').filter(Boolean); + if (params.has('setCode')) state.filters.setCode = params.get('setCode').split(',').filter(Boolean); + if (params.has('powerMin')) state.filters.powerMin = parseFloat(params.get('powerMin')); + if (params.has('powerMax')) state.filters.powerMax = parseFloat(params.get('powerMax')); + if (params.has('toughMin')) state.filters.toughMin = parseFloat(params.get('toughMin')); + if (params.has('toughMax')) state.filters.toughMax = parseFloat(params.get('toughMax')); + if (params.has('keyword')) state.filters.keyword = params.get('keyword').split(',').filter(Boolean); + if (params.has('edhrecMin')) state.filters.edhrecMin = parseInt(params.get('edhrecMin')); + if (params.has('edhrecMax')) state.filters.edhrecMax = parseInt(params.get('edhrecMax')); + if (params.has('qtyMin')) state.filters.qtyMin = parseInt(params.get('qtyMin')); + if (params.has('qtyMax')) state.filters.qtyMax = parseInt(params.get('qtyMax')); + } + + function pushUrlState() { + const params = new URLSearchParams(); + if (state.search) params.set('q', state.search); + if (state.page > 1) params.set('page', state.page); + if (state.pageSize !== 50) params.set('pageSize', state.pageSize); + if (state.sortField !== 'name') params.set('sortField', state.sortField); + if (state.sortDir !== 'asc') params.set('sortDir', state.sortDir); + if (state.filters.colors.length) params.set('colors', state.filters.colors.join(',')); + if (state.filters.types.length) params.set('types', state.filters.types.join(',')); + if (state.filters.finish) params.set('finish', state.filters.finish); + if (state.filters.category.length) params.set('category', state.filters.category.join(',')); + if (state.filters.isLegendary) params.set('isLegendary', 'true'); + if (state.filters.isBasic) params.set('isBasic', 'true'); + if (state.filters.isGameChanger) params.set('isGameChanger', 'true'); + if (state.filters.highSalt) params.set('highSalt', 'true'); + if (state.filters.cmcMin != null) params.set('cmcMin', state.filters.cmcMin); + if (state.filters.cmcMax != null) params.set('cmcMax', state.filters.cmcMax); + if (state.filters.priceMin != null) params.set('priceMin', state.filters.priceMin); + if (state.filters.priceMax != null) params.set('priceMax', state.filters.priceMax); + if (state.filters.rarity.length) params.set('rarity', state.filters.rarity.join(',')); + if (state.filters.setCode.length) params.set('setCode', state.filters.setCode.join(',')); + if (state.filters.powerMin != null) params.set('powerMin', state.filters.powerMin); + if (state.filters.powerMax != null) params.set('powerMax', state.filters.powerMax); + if (state.filters.toughMin != null) params.set('toughMin', state.filters.toughMin); + if (state.filters.toughMax != null) params.set('toughMax', state.filters.toughMax); + if (state.filters.keyword.length) params.set('keyword', state.filters.keyword.join(',')); + if (state.filters.edhrecMin != null) params.set('edhrecMin', state.filters.edhrecMin); + if (state.filters.edhrecMax != null) params.set('edhrecMax', state.filters.edhrecMax); + if (state.filters.qtyMin != null) params.set('qtyMin', state.filters.qtyMin); + if (state.filters.qtyMax != null) params.set('qtyMax', state.filters.qtyMax); + const qs = params.toString(); + history.replaceState(null, '', qs ? `?${qs}` : window.location.pathname); + } + + // ── API ───────────────────────────────────────────────── + + function buildCollectionQuery() { + const p = new URLSearchParams(); + p.set('page', state.page); + p.set('pageSize', state.pageSize); + p.set('sortField', state.sortField); + p.set('sortDir', state.sortDir); + if (state.search) p.set('q', state.search); + const f = state.filters; + if (f.colors.length) p.set('colors', f.colors.join(',')); + if (f.types.length) p.set('types', f.types.join(',')); + if (f.finish) p.set('finish', f.finish); + if (f.category.length) p.set('category', f.category.join(',')); + if (f.isLegendary) p.set('isLegendary', 'true'); + if (f.isBasic) p.set('isBasic', 'true'); + if (f.isGameChanger) p.set('isGameChanger', 'true'); + if (f.highSalt) p.set('highSalt', 'true'); + if (f.cmcMin != null) p.set('cmcMin', f.cmcMin); + if (f.cmcMax != null) p.set('cmcMax', f.cmcMax); + if (f.priceMin != null) p.set('priceMin', f.priceMin); + if (f.priceMax != null) p.set('priceMax', f.priceMax); + if (f.rarity.length) p.set('rarity', f.rarity.join(',')); + if (f.setCode.length) p.set('setCode', f.setCode.join(',')); + if (f.powerMin != null) p.set('powerMin', f.powerMin); + if (f.powerMax != null) p.set('powerMax', f.powerMax); + if (f.toughMin != null) p.set('toughMin', f.toughMin); + if (f.toughMax != null) p.set('toughMax', f.toughMax); + if (f.keyword.length) p.set('keyword', f.keyword.join(',')); + if (f.edhrecMin != null) p.set('edhrecMin', f.edhrecMin); + if (f.edhrecMax != null) p.set('edhrecMax', f.edhrecMax); + if (f.qtyMin != null) p.set('qtyMin', f.qtyMin); + if (f.qtyMax != null) p.set('qtyMax', f.qtyMax); + return p.toString(); + } + + async function fetchCollection() { + if (state.loading) return; + state.loading = true; + showLoading(true); + try { + const qs = buildCollectionQuery(); + const res = await fetch(`${API_BASE}/api/collection?${qs}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + state.items = data.items || []; + state.total = data.total || 0; + state.page = data.page || state.page; + } catch (err) { + console.error('[Collection] fetchCollection error:', err); + state.items = []; + state.total = 0; + } finally { + state.loading = false; + showLoading(false); + } + renderTable(); + renderSortBar(); + renderPagination(); + renderCountBadge(); + } + + // ── Stats Dashboard ────────────────────────────────────── + let statsData = null; + let statsOpen = false; + + async function fetchStats() { + try { + const res = await fetch(`${API_BASE}/api/collection/stats`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + statsData = await res.json(); + } catch (err) { + console.error('[Collection] fetchStats error:', err); + statsData = null; + } + } + + function toggleStats() { + statsOpen = !statsOpen; + const panel = document.getElementById('coll-stats-panel'); + if (!panel) return; + if (statsOpen) { + if (!statsData) { + panel.innerHTML = '
Loading stats…
'; + panel.style.display = 'block'; + fetchStats().then(() => renderStatsPanel()); + } else { + renderStatsPanel(); + } + panel.style.display = 'block'; + } else { + panel.style.display = 'none'; + } + } + + function renderStatsPanel() { + const panel = document.getElementById('coll-stats-panel'); + if (!panel || !statsData) return; + const s = statsData; + + // CMC curve bar chart (CSS-only) + const cmcMax = Math.max(...Object.values(s.cmcCurve || {}), 1); + const cmcBars = [0,1,2,3,4,5,6,7].map(i => { + const count = (s.cmcCurve || {})[String(i)] || 0; + const pct = Math.round((count / cmcMax) * 100); + const label = i === 7 ? '7+' : String(i); + return `
+
+ ${label} +
`; + }).join(''); + + // Color distribution + const colorTotal = Math.max(Object.values(s.colorDistribution || {}).reduce((a,b) => a+b, 0), 1); + const colorBars = ['W','U','B','R','G','C'].map(c => { + const count = (s.colorDistribution || {})[c] || 0; + const pct = Math.round((count / colorTotal) * 100); + const ci = COLOR_MAP[c]; + return `
+ ${ci.label} +
+ ${count} +
`; + }).join(''); + + // Type distribution + const typeTotal = Math.max(Object.values(s.typeDistribution || {}).reduce((a,b) => a+b, 0), 1); + const typeBars = Object.entries(s.typeDistribution || {}).sort((a,b) => b[1]-a[1]).map(([t, count]) => { + const pct = Math.round((count / typeTotal) * 100); + return `
+ ${escapeHtml(t)} +
+ ${count} +
`; + }).join(''); + + // Rarity distribution + const rarityOrder = ['common','uncommon','rare','mythic']; + const rarityColors = {common:'#706f6f', uncommon:'#859ba6', rare:'#c9a849', mythic:'#d35400'}; + const rarityTotal = Math.max(Object.values(s.rarityDistribution || {}).reduce((a,b) => a+b, 0), 1); + const rarityBars = rarityOrder.map(r => { + const count = (s.rarityDistribution || {})[r] || 0; + const pct = Math.round((count / rarityTotal) * 100); + return `
+ ${r.charAt(0).toUpperCase() + r.slice(1)} +
+ ${count} +
`; + }).join(''); + + // Top value cards + const topCards = (s.topValueCards || []).map(c => + `
${escapeHtml(c.name)}$${Number(c.price).toFixed(2)}
` + ).join(''); + + // Category/role coverage + const catEntries = Object.entries(s.categoryDistribution || {}).sort((a,b) => b[1]-a[1]).slice(0, 12); + const catMax = Math.max(...catEntries.map(e => e[1]), 1); + const catBars = catEntries.map(([cat, count]) => { + const pct = Math.round((count / catMax) * 100); + return `
+ ${escapeHtml(cat)} +
+ ${count} +
`; + }).join(''); + + panel.innerHTML = ` +
+
+
${(s.totalCards || 0).toLocaleString()}
Total Cards
+
${(s.totalUniqueCards || 0).toLocaleString()}
Unique
+
$${(s.totalValue || 0).toLocaleString(undefined,{minimumFractionDigits:2})}
Total Value
+
${s.avgCmc || 0}
Avg CMC
+
+
+

CMC Curve

+
${cmcBars}
+
+
+

Color Distribution

+ ${colorBars} +
+
+

Type Distribution

+ ${typeBars} +
+
+

Rarity

+ ${rarityBars} +
+
+

Role Coverage

+ ${catBars || '
No categories assigned yet
'} +
+
+

Top Value Cards

+ ${topCards || '
No priced cards
'} +
+
+ `; + } + + + async function fetchCardDetail(cardId) { + const res = await fetch(`${API_BASE}/api/collection/${cardId}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + } + + async function fetchEdhrecData(cardId) { + const res = await fetch(`${API_BASE}/api/collection/${cardId}/edhrec`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + } + + async function patchCard(cardId, payload) { + const res = await fetch(`${API_BASE}/api/collection/${cardId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + } + + // ── Init ──────────────────────────────────────────────── + + async function init() { + loadPreferences(); + syncStateFromUrl(); + renderPage(); + bindEvents(); + adjustTableHeight(); + window.addEventListener('resize', adjustTableHeight); + // Pre-load set/keyword caches for filter autocomplete + loadSetsCache(); + loadKeywordsCache(); + applyColumnVisibility(); + await fetchCollection(); + } + + /** Adjust table container height based on actual topbar height */ + function adjustTableHeight() { + const topbar = document.querySelector('.coll-topbar'); + const tableWrap = document.querySelector('.coll-table-wrap'); + if (topbar && tableWrap) { + const topbarH = topbar.offsetHeight; + // 16px padding-top of coll-main + 16px padding-bottom + tableWrap.style.maxHeight = 'calc(100vh - ' + (topbarH + 48) + 'px)'; + } + } + + // ── Page Render ───────────────────────────────────────── + + function renderPage() { + const container = document.getElementById('collection-container'); + if (!container) return; + + container.innerHTML = ` +
+ + + + + +
+
+

Collection

+ 0 cards +
+
+ + + + + + + +
+ + +
+ ⚔ Deck Builder + ⚡ Auto Gen +
+
+ + + + +
+ + + + + +
+ ${renderSortBarHtml()} +
+ + ${renderTableHeaderHtml()} + +
+
+
+ + +
+ +
+ + +
+
+
+
+ + + + + +
+
+
+ + +
+
+
+ + +
+
+
+ +
`; + + renderTable(); + renderPagination(); + } + + // ── Sort Bar HTML ─────────────────────────────────────── + + function renderSortBarHtml() { + const sortOptions = [ + { value: 'name', label: 'Name' }, + { value: 'tcg_price', label: 'Price' }, + { value: 'cmc', label: 'CMC / Mana Value' }, + { value: 'quantity', label: 'Quantity' }, + { value: 'type_line', label: 'Type' }, + { value: 'rarity', label: 'Rarity' }, + { value: 'edhrec_rank',label: 'EDHREC Rank' }, + { value: 'salt_score', label: 'Salt Score' }, + { value: 'set_code', label: 'Set' }, + { value: 'finish', label: 'Finish' }, + ]; + const opts = sortOptions.map(o => + `` + ).join(''); + const dirLabel = state.sortDir === 'asc' ? '▲ Asc' : '▼ Desc'; + return ` +
+ Sort by + + + ${state.total.toLocaleString()} cards +
`; + } + + function renderSortBar() { + const bar = document.querySelector('.coll-sort-bar'); + if (bar) { + bar.outerHTML = renderSortBarHtml(); + } + } + + function setSortFromBar(field) { + state.sortField = field; + state.page = 1; + savePreferences(); + pushUrlState(); + fetchCollection(); + } + + function toggleSortDir() { + state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc'; + state.page = 1; + savePreferences(); + pushUrlState(); + fetchCollection(); + } + + // ── Filter Panel HTML ─────────────────────────────────── + + function renderFiltersHtml() { + const f = state.filters; + + // Color buttons + const colorBtns = Object.entries(COLOR_MAP).map(([key, ci]) => { + const active = f.colors.includes(key) ? 'active' : ''; + return ``; + }).join(''); + + // Type checkboxes + const typeChecks = CARD_TYPES.map(t => { + const checked = f.types.includes(t) ? 'checked' : ''; + return ``; + }).join(''); + + // Toggles + const toggles = [ + { key: 'isLegendary', label: 'Legendary' }, + { key: 'isBasic', label: 'Basics' }, + { key: 'isGameChanger', label: 'Game Changers' }, + { key: 'highSalt', label: 'High Salt' }, + ].map(({ key, label }) => { + const active = f[key] ? 'active' : ''; + return ``; + }).join(''); + + // Finish radios + const finishes = [ + { value: '', label: 'All' }, + { value: 'NORMAL', label: 'Normal' }, + { value: 'FOIL', label: 'Foil' }, + { value: 'ETCHED', label: 'Etched' }, + ].map(({ value, label }) => { + const checked = f.finish === value ? 'checked' : ''; + return ``; + }).join(''); + + // Category chips + const catChips = CATEGORY_SUGGESTIONS.map(cat => { + const active = f.category.includes(cat) ? 'active' : ''; + return ``; + }).join(''); + + // Rarity buttons + const RARITIES = [ + { value: 'common', label: 'C', title: 'Common' }, + { value: 'uncommon', label: 'U', title: 'Uncommon' }, + { value: 'rare', label: 'R', title: 'Rare' }, + { value: 'mythic', label: 'M', title: 'Mythic' }, + ]; + const rarityBtns = RARITIES.map(r => { + const active = f.rarity.includes(r.value) ? 'active' : ''; + return ``; + }).join(''); + + // Set filter chips (show selected sets) + const setChips = f.setCode.map(code => { + const found = state.cachedSets.find(s => s.code === code); + const label = found ? found.name : code; + return `${escapeHtml(label)} `; + }).join(''); + + // Keyword filter chips + const kwChips = f.keyword.map(kw => + `${escapeHtml(kw)} ` + ).join(''); + + return ` +
+
Color Identity
+
${colorBtns}
+
+ +
+
Card Type
+
${typeChecks}
+
+ +
+
Rarity
+
${rarityBtns}
+
+ +
+
Properties
+
${toggles}
+
+ +
+
Finish
+
${finishes}
+
+ +
+
CMC Range
+
+ + – + +
+
+ +
+
Power / Toughness
+
+
+ Pow +
+ + – + +
+
+
+ Tgh +
+ + – + +
+
+
+
+ +
+
Price Range ($)
+
+ + – + +
+
+ +
+
EDHREC Rank
+
+ + – + +
+
+ +
+
Owned Quantity
+
+ + – + +
+
+ +
+
Set
+
+ +
+
${setChips}
+
+
+ +
+
Keywords
+
+ +
+
${kwChips}
+
+
+ +
+
Category
+
${catChips}
+
+ +
+ +
`; + } + + // ── Column Visibility ────────────────────────────────────── + function colVis(key) { + return state.preferences.visibleColumns.includes(key) ? '' : 'display:none;'; + } + + function toggleColumn(key) { + const cols = state.preferences.visibleColumns; + const idx = cols.indexOf(key); + if (idx >= 0) { + if (cols.length <= 1) return; // keep at least 1 column + cols.splice(idx, 1); + } else { + cols.push(key); + } + savePreferences(); + renderTable(); + applyColumnVisibility(); + } + + function renderColumnPanel() { + const panel = document.getElementById('coll-col-panel'); + if (!panel) return; + const cols = COLUMNS.map(col => { + const checked = state.preferences.visibleColumns.includes(col.key) ? 'checked' : ''; + return ``; + }).join(''); + panel.innerHTML = ` +
+
Columns
+ ${cols} + +
`; + panel.style.display = 'block'; + } + + function openColumnPanel() { + const panel = document.getElementById('coll-col-panel'); + if (!panel) return; + if (panel.style.display === 'block') { + panel.style.display = 'none'; + } else { + renderColumnPanel(); + } + } + + function closeColumnPanel() { + const panel = document.getElementById('coll-col-panel'); + if (panel) panel.style.display = 'none'; + } + + function resetColumns() { + state.preferences.visibleColumns = ['quantity','name','mana_cost','type_line','color_identity','cmc','power_toughness','rarity','set_code','tcg_price','edhrec_rank','salt_score','category','finish','oracle_text']; + savePreferences(); + renderTable(); + renderColumnPanel(); + } + function applyColumnVisibility() { + let styleEl = document.getElementById('coll-col-vis-style'); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = 'coll-col-vis-style'; + document.head.appendChild(styleEl); + } + const hidden = COLUMNS.filter(col => !state.preferences.visibleColumns.includes(col.key)); + styleEl.textContent = hidden.map(col => + `.coll-th-${col.key}, .coll-td-${col.key} { display: none !important; }` + ).join('\n'); + } + + + // ── Table Header ──────────────────────────────────────── + + const COLUMNS = [ + { key: 'quantity', label: 'Qty', sortable: true }, + { key: 'name', label: 'Name', sortable: true }, + { key: 'mana_cost', label: 'Mana', sortable: false }, + { key: 'type_line', label: 'Type', sortable: true }, + { key: 'color_identity',label: 'Colors', sortable: false }, + { key: 'cmc', label: 'CMC', sortable: true }, + { key: 'power_toughness',label: 'P/T', sortable: false }, + { key: 'rarity', label: 'Rarity', sortable: true }, + { key: 'set_code', label: 'Set', sortable: true }, + { key: 'tcg_price', label: 'Price', sortable: true }, + { key: 'edhrec_rank', label: 'EDHREC', sortable: true }, + { key: 'salt_score', label: 'Salt', sortable: true }, + { key: 'is_game_changer',label: 'GC', sortable: false }, + { key: 'category', label: 'Category', sortable: true }, + { key: 'finish', label: 'Finish', sortable: true }, + { key: 'oracle_text', label: 'Oracle Text', sortable: false }, + ]; + + function renderTableHeaderHtml() { + const visibleCols = COLUMNS.filter(col => state.preferences.visibleColumns.includes(col.key)); + const headers = visibleCols.map(col => { if (!col.sortable) { + return `${col.label}`; + } + const isActive = state.sortField === col.key; + const dir = isActive ? state.sortDir : ''; + const arrow = isActive ? (dir === 'asc' ? ' ▲' : ' ▼') : ''; + const activeClass = isActive ? ' coll-th-active' : ''; + return `${col.label}${arrow}`; + }).join(''); + return `${headers}`; + } + + // ── Table Body ────────────────────────────────────────── + + function renderTable() { + const tbody = document.getElementById('coll-tbody'); + if (!tbody) return; + + // Re-render header (sort indicators may have changed) + const table = document.getElementById('coll-table'); + if (table) { + const oldThead = table.querySelector('thead'); + if (oldThead) { + oldThead.outerHTML = renderTableHeaderHtml(); + } + } + + if (state.loading) { + tbody.innerHTML = `Loading…`; + return; + } + + if (state.items.length === 0) { + tbody.innerHTML = ` +
+
🃏
+
No cards in collection
+ +
+ `; + return; + } + + tbody.innerHTML = state.items.map(card => { + const saltCls = saltClass(card.saltScore); + const oracleTrunc = truncate(card.oracleText || '', 60); + const gcIcon = card.isGameChanger ? '★' : ''; + const legIcon = card.isLegendary ? '◆' : ''; + const basicIcon = card.isBasic ? '⬡' : ''; + + const manaCost = renderManaCost(card.manaCost || card.mana_cost || ''); + const pt = (card.power || card.toughness) + ? `${escapeHtml(card.power || '—')}/${escapeHtml(card.toughness || '—')}` + : '—'; + const rarity = card.rarity ? `${escapeHtml(card.rarity)}` : '—'; + const setName = escapeHtml(card.setName || card.set_name || ''); + const edhrecRank = (card.edhrecRank || card.edhrec_rank) ? `#${card.edhrecRank || card.edhrec_rank}` : '—'; + + return ` + + ${escapeHtml(card.quantity ?? 1)} + + ${escapeHtml(card.name)} + ${legIcon}${basicIcon} + + ${manaCost} + ${escapeHtml(card.typeLine || card.type_line || '')} + ${renderColorPips(card.colorIdentity || card.color_identity)} + ${card.cmc ?? '—'} + ${pt} + ${rarity} + ${setName} + ${formatPrice(card.tcgPrice || card.tcg_price)} + ${edhrecRank} + ${(card.saltScore ?? card.salt_score) != null ? Number(card.saltScore ?? card.salt_score).toFixed(2) : '—'} + ${gcIcon} + ${renderCategoryChips(card.category || [], card.id)} + ${renderFinishBadge(card.finish)} + ${escapeHtml(oracleTrunc)} + `; + }).join(''); + CollCardPreview.attachAll(); + } + + // ── Pagination ────────────────────────────────────────── + + function renderPagination() { + const el = document.getElementById('coll-pagination'); + if (!el) return; + const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize)); + const prevDisabled = state.page <= 1 ? 'disabled' : ''; + const nextDisabled = state.page >= totalPages ? 'disabled' : ''; + + el.innerHTML = ` +
+ Page ${state.page} of ${totalPages} + (${state.total} total) +
+
+ + +
+
+ Show: + +
`; + } + + function renderCountBadge() { + const el = document.getElementById('coll-count'); + if (el) el.textContent = `${state.total.toLocaleString()} card${state.total !== 1 ? 's' : ''}`; + } + + // ── Sort / Page / Filter Actions ───────────────────────── + + function setSort(field) { + if (state.sortField === field) { + state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc'; + } else { + state.sortField = field; + state.sortDir = 'asc'; + } + state.page = 1; + savePreferences(); + pushUrlState(); + fetchCollection(); + } + + function setPage(p) { + const totalPages = Math.max(1, Math.ceil(state.total / state.pageSize)); + state.page = Math.max(1, Math.min(p, totalPages)); + pushUrlState(); + fetchCollection(); + } + + function setPageSize(n) { + state.pageSize = n; + state.page = 1; + savePreferences(); + pushUrlState(); + fetchCollection(); + } + + function toggleColor(color) { + const idx = state.filters.colors.indexOf(color); + if (idx >= 0) { + state.filters.colors.splice(idx, 1); + } else { + state.filters.colors.push(color); + } + state.page = 1; + pushUrlState(); + refreshFiltersPanel(); + fetchCollection(); + } + + function toggleType(type) { + const idx = state.filters.types.indexOf(type); + if (idx >= 0) { + state.filters.types.splice(idx, 1); + } else { + state.filters.types.push(type); + } + state.page = 1; + pushUrlState(); + fetchCollection(); + } + + function toggleFilter(key) { + state.filters[key] = !state.filters[key]; + state.page = 1; + pushUrlState(); + refreshFiltersPanel(); + fetchCollection(); + } + + function setFinish(value) { + state.filters.finish = value; + state.page = 1; + pushUrlState(); + fetchCollection(); + } + + function setCmcRange() { + const minEl = document.getElementById('coll-cmc-min'); + const maxEl = document.getElementById('coll-cmc-max'); + state.filters.cmcMin = minEl?.value !== '' ? parseFloat(minEl.value) : null; + state.filters.cmcMax = maxEl?.value !== '' ? parseFloat(maxEl.value) : null; + state.page = 1; + pushUrlState(); + fetchCollection(); + } + + function setPriceRange() { + const minEl = document.getElementById('coll-price-min'); + const maxEl = document.getElementById('coll-price-max'); + state.filters.priceMin = minEl?.value !== '' ? parseFloat(minEl.value) : null; + state.filters.priceMax = maxEl?.value !== '' ? parseFloat(maxEl.value) : null; + state.page = 1; + pushUrlState(); + fetchCollection(); + } + + function toggleCategoryFilter(cat) { + const idx = state.filters.category.indexOf(cat); + if (idx >= 0) { + state.filters.category.splice(idx, 1); + } else { + state.filters.category.push(cat); + } + state.page = 1; + pushUrlState(); + refreshFiltersPanel(); + fetchCollection(); + } + + // ── New filter action handlers ──────────────────────────── + + function toggleRarity(value) { + const idx = state.filters.rarity.indexOf(value); + if (idx >= 0) state.filters.rarity.splice(idx, 1); + else state.filters.rarity.push(value); + state.page = 1; + pushUrlState(); + refreshFiltersPanel(); + fetchCollection(); + } + + function setPowerRange() { + const minEl = document.getElementById('coll-pow-min'); + const maxEl = document.getElementById('coll-pow-max'); + state.filters.powerMin = minEl?.value !== '' ? parseFloat(minEl.value) : null; + state.filters.powerMax = maxEl?.value !== '' ? parseFloat(maxEl.value) : null; + state.page = 1; + pushUrlState(); + fetchCollection(); + } + + function setToughRange() { + const minEl = document.getElementById('coll-tgh-min'); + const maxEl = document.getElementById('coll-tgh-max'); + state.filters.toughMin = minEl?.value !== '' ? parseFloat(minEl.value) : null; + state.filters.toughMax = maxEl?.value !== '' ? parseFloat(maxEl.value) : null; + state.page = 1; + pushUrlState(); + fetchCollection(); + } + + function setEdhrecRange() { + const minEl = document.getElementById('coll-edhrec-min'); + const maxEl = document.getElementById('coll-edhrec-max'); + state.filters.edhrecMin = minEl?.value !== '' ? parseInt(minEl.value) : null; + state.filters.edhrecMax = maxEl?.value !== '' ? parseInt(maxEl.value) : null; + state.page = 1; + pushUrlState(); + fetchCollection(); + } + + function setQtyRange() { + const minEl = document.getElementById('coll-qty-min'); + const maxEl = document.getElementById('coll-qty-max'); + state.filters.qtyMin = minEl?.value !== '' ? parseInt(minEl.value) : null; + state.filters.qtyMax = maxEl?.value !== '' ? parseInt(maxEl.value) : null; + state.page = 1; + pushUrlState(); + fetchCollection(); + } + + // Set autocomplete + async function loadSetsCache() { + if (state.cachedSets.length) return; + try { + const res = await fetch(API_BASE + '/api/collection/sets'); + if (res.ok) state.cachedSets = await res.json(); + } catch (e) { /* ignore */ } + } + + function onSetInput(val) { + const dd = document.getElementById('coll-set-dropdown'); + if (!dd) return; + if (!val || val.length < 1) { dd.innerHTML = ''; dd.style.display = 'none'; return; } + const q = val.toLowerCase(); + const matches = state.cachedSets + .filter(s => !state.filters.setCode.includes(s.code)) + .filter(s => s.code.toLowerCase().includes(q) || (s.name || '').toLowerCase().includes(q)) + .slice(0, 12); + if (!matches.length) { dd.innerHTML = ''; dd.style.display = 'none'; return; } + dd.style.display = 'block'; + dd.innerHTML = matches.map(s => + `
+ ${escapeHtml(s.code)} + ${escapeHtml(s.name || '')} +
` + ).join(''); + } + + function addSetFilter(code) { + if (!state.filters.setCode.includes(code)) { + state.filters.setCode.push(code); + state.page = 1; + pushUrlState(); + refreshFiltersPanel(); + fetchCollection(); + } + const dd = document.getElementById('coll-set-dropdown'); + if (dd) { dd.innerHTML = ''; dd.style.display = 'none'; } + } + + function removeSetFilter(code) { + state.filters.setCode = state.filters.setCode.filter(c => c !== code); + state.page = 1; + pushUrlState(); + refreshFiltersPanel(); + fetchCollection(); + } + + // Keyword autocomplete + async function loadKeywordsCache() { + if (state.cachedKeywords.length) return; + try { + const res = await fetch(API_BASE + '/api/collection/keywords'); + if (res.ok) state.cachedKeywords = await res.json(); + } catch (e) { /* ignore */ } + } + + function onKwInput(val) { + const dd = document.getElementById('coll-kw-dropdown'); + if (!dd) return; + if (!val || val.length < 1) { dd.innerHTML = ''; dd.style.display = 'none'; return; } + const q = val.toLowerCase(); + const matches = state.cachedKeywords + .filter(k => !state.filters.keyword.includes(k)) + .filter(k => k.toLowerCase().includes(q)) + .slice(0, 12); + if (!matches.length) { dd.innerHTML = ''; dd.style.display = 'none'; return; } + dd.style.display = 'block'; + dd.innerHTML = matches.map(k => + `
+ ${escapeHtml(k)} +
` + ).join(''); + } + + function addKeywordFilter(kw) { + if (!state.filters.keyword.includes(kw)) { + state.filters.keyword.push(kw); + state.page = 1; + pushUrlState(); + refreshFiltersPanel(); + fetchCollection(); + } + const dd = document.getElementById('coll-kw-dropdown'); + if (dd) { dd.innerHTML = ''; dd.style.display = 'none'; } + } + + function removeKeywordFilter(kw) { + state.filters.keyword = state.filters.keyword.filter(k => k !== kw); + state.page = 1; + pushUrlState(); + refreshFiltersPanel(); + fetchCollection(); + } + + function resetFilters() { + state.filters = { + colors: [], types: [], isLegendary: false, isBasic: false, + isGameChanger: false, highSalt: false, finish: '', + cmcMin: null, cmcMax: null, priceMin: null, priceMax: null, + category: [], rarity: [], setCode: [], + powerMin: null, powerMax: null, toughMin: null, toughMax: null, + keyword: [], edhrecMin: null, edhrecMax: null, + qtyMin: null, qtyMax: null, + }; + state.page = 1; + state.search = ''; + const searchEl = document.getElementById('coll-search'); + if (searchEl) searchEl.value = ''; + pushUrlState(); + refreshFiltersPanel(); + fetchCollection(); + } + + function refreshFiltersPanel() { + const panel = document.getElementById('coll-filters'); + if (panel) panel.innerHTML = renderFiltersHtml(); + } + + // ── Detail Drawer ──────────────────────────────────────── + + async function openDrawer(cardId) { + state.selectedCard = null; + state.edhrecData = null; + state.drawerTab = 'overview'; + + const drawer = document.getElementById('coll-drawer'); + const backdrop = document.getElementById('coll-drawer-backdrop'); + const inner = document.getElementById('coll-drawer-inner'); + if (!drawer) return; + + drawer.classList.add('open'); + if (backdrop) backdrop.classList.add('open'); + state.drawerOpen = true; + + inner.innerHTML = `
`; + + try { + const card = await fetchCardDetail(cardId); + state.selectedCard = card; + renderDrawer(); + } catch (err) { + inner.innerHTML = `
Failed to load card: ${escapeHtml(err.message)}
`; + } + } + + function closeDrawer() { + const drawer = document.getElementById('coll-drawer'); + const backdrop = document.getElementById('coll-drawer-backdrop'); + if (drawer) drawer.classList.remove('open'); + if (backdrop) backdrop.classList.remove('open'); + state.drawerOpen = false; + state.selectedCard = null; + closeCategoryPopover(); + } + + function renderDrawer() { + const inner = document.getElementById('coll-drawer-inner'); + if (!inner || !state.selectedCard) return; + const card = state.selectedCard; + + const tabContent = renderDrawerTabContent(); + + inner.innerHTML = ` +
+
+ ${escapeHtml(card.name)} + +
+
+ ${renderColorPips(card.colorIdentity)} + ${escapeHtml(card.typeLine || '')} + ${card.isLegendary ? '◆ Legendary' : ''} +
+
+ +
+
+ ${card.imageUrl + ? `${escapeAttr(card.name)}` + : `
No Image
`} +
+ +
+ + + +
+ +
+ ${tabContent} +
+
`; + } + + function renderDrawerTabContent() { + if (state.drawerTab === 'overview') return renderOverviewTab(); + if (state.drawerTab === 'edhrec') return renderEdhrecTab(); + if (state.drawerTab === 'decks') return renderDecksTab(); + return ''; + } + + function renderOverviewTab() { + const card = state.selectedCard; + if (!card) return ''; + + const categories = Array.isArray(card.category) ? card.category : []; + const tags = card.tags ? card.tags.split(',').map(t => t.trim()).filter(Boolean) : []; + + const catChips = categories.map(cat => + `${escapeHtml(cat)} + + ` + ).join(''); + + const catSuggestions = CATEGORY_SUGGESTIONS.filter(s => !categories.includes(s)).map(s => + `` + ).join(''); + + const _mc = card.manaCost || card.mana_cost || ''; + const _pw = card.power || ''; + const _tg = card.toughness || ''; + const _pt = (_pw || _tg) ? `${_pw || '—'}/${_tg || '—'}` : ''; + const _rr = card.rarity || ''; + const _sn = card.setName || card.set_name || ''; + const _er = card.edhrecRank || card.edhrec_rank || 0; + + return ` +
+
Mana Cost${renderManaCost(_mc)}
+
CMC${card.cmc ?? '—'}
+ ${_pt ? `
P/T${escapeHtml(_pt)}
` : ''} +
Rarity${escapeHtml(_rr || '—')}
+
Set + ${escapeHtml(_sn || card.setCode || card.set_code || '')} ${card.collectorNumber || card.collector_number ? `#${escapeHtml(card.collectorNumber || card.collector_number)}` : ''} +
+
Price${formatPrice(card.tcgPrice || card.tcg_price)}
+ ${_er ? `
EDHREC Rank#${_er}
` : ''} +
Salt + ${(card.saltScore ?? card.salt_score) != null ? Number(card.saltScore ?? card.salt_score).toFixed(2) : '—'} +
+
Game Changer + ${(card.isGameChanger || card.is_game_changer) ? '★ Yes' : 'No'} +
+
Finish${renderFinishBadge(card.finish)}
+ ${card.keywords && card.keywords.length + ? `
Keywords${escapeHtml(card.keywords.join(', '))}
` + : ''} +
+ + ${card.oracleText + ? `
${escapeHtml(card.oracleText).replace(/\n/g, '
')}
` + : ''} + +
+ + +
+ +
+ +
${catChips}
+
${catSuggestions}
+
+ + +
+
+ +
+ +
+ ${tags.map(tag => + `${escapeHtml(tag)} + + ` + ).join('')} +
+
+ + +
+
+ +
+ + +
+ +
+ + +
`; + } + + function renderEdhrecTab() { + if (state.edhrecLoading) { + return `
Loading EDHREC data…
`; + } + if (!state.edhrecData) { + return `
+

Fetch EDHREC recommendations for this card.

+ +
`; + } + + const data = state.edhrecData; + const recs = data.recommendations || []; + + const links = data.links || {}; + const linksHtml = [ + links.edhrecPage && `EDHREC Page`, + links.archidektSearchUri && `Archidekt Search`, + links.moxfieldSearchUri && `Moxfield Search`, + ].filter(Boolean).join(''); + + if (recs.length === 0) { + return `
+

No EDHREC recommendations found.

+ ${linksHtml ? `` : ''} +
`; + } + + const recRows = recs.map(rec => ` +
+
${escapeHtml(rec.name || '')}
+
+ ${rec.role ? `${escapeHtml(rec.role)}` : ''} + ${rec.synergy != null ? `${Number(rec.synergy).toFixed(0)}%` : ''} + ${rec.inclusion != null ? `${Number(rec.inclusion).toFixed(0)}%` : ''} +
+
`).join(''); + + return ` +
${recRows}
+ ${linksHtml ? `` : ''}`; + } + + function renderDecksTab() { + const card = state.selectedCard; + if (!card) return ''; + + const name = encodeURIComponent(card.name); + const archidektUrl = `https://www.archidekt.com/search?q=${name}&format=commander`; + const moxfieldUrl = `https://www.moxfield.com/search?q=${name}`; + const edhrecUrl = `https://edhrec.com/cards/${name.toLowerCase().replace(/\s+/g, '-')}`; + + return ` +
+

Search for decks featuring ${escapeHtml(card.name)}:

+ +
`; + } + + function switchDrawerTab(tab) { + state.drawerTab = tab; + if (tab === 'edhrec' && !state.edhrecData && !state.edhrecLoading) { + loadEdhrecTab(); + return; + } + const content = document.getElementById('coll-drawer-tab-content'); + if (content) content.innerHTML = renderDrawerTabContent(); + // Update tab button states + document.querySelectorAll('.coll-tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.textContent.toLowerCase().includes(tab)); + }); + } + + async function loadEdhrecTab() { + if (!state.selectedCard) return; + state.edhrecLoading = true; + state.edhrecData = null; + const content = document.getElementById('coll-drawer-tab-content'); + if (content) content.innerHTML = renderEdhrecTab(); + + try { + const data = await fetchEdhrecData(state.selectedCard.id); + state.edhrecData = data; + } catch (err) { + state.edhrecData = { error: err.message, recommendations: [], links: {} }; + } finally { + state.edhrecLoading = false; + } + + if (content) content.innerHTML = renderEdhrecTab(); + } + + // ── Drawer Editing ─────────────────────────────────────── + + function addDrawerCategory(cat) { + if (!state.selectedCard) return; + const categories = Array.isArray(state.selectedCard.category) ? [...state.selectedCard.category] : []; + if (!categories.includes(cat)) { + categories.push(cat); + state.selectedCard.category = categories; + refreshDrawerCategories(); + saveCardEdits(); + } + } + + function removeDrawerCategory(cat) { + if (!state.selectedCard) return; + state.selectedCard.category = (state.selectedCard.category || []).filter(c => c !== cat); + refreshDrawerCategories(); + saveCardEdits(); + } + + function addDrawerCategoryFromInput() { + const input = document.getElementById('coll-drawer-cat-input'); + if (!input) return; + const val = input.value.trim(); + if (val) { + addDrawerCategory(val); + input.value = ''; + } + } + + function refreshDrawerCategories() { + const card = state.selectedCard; + if (!card) return; + const categories = Array.isArray(card.category) ? card.category : []; + const chipsEl = document.getElementById('coll-drawer-cat-chips'); + if (chipsEl) { + chipsEl.innerHTML = categories.map(cat => + `${escapeHtml(cat)} + + ` + ).join(''); + } + // Refresh suggestions + const suggestEl = document.querySelector('.coll-cat-suggestions'); + if (suggestEl) { + const catSuggestions = CATEGORY_SUGGESTIONS.filter(s => !categories.includes(s)).map(s => + `` + ).join(''); + suggestEl.innerHTML = catSuggestions; + } + } + + function addDrawerTagFromInput() { + const input = document.getElementById('coll-drawer-tag-input'); + if (!input || !state.selectedCard) return; + const val = input.value.trim(); + if (!val) return; + const tagsStr = state.selectedCard.tags || ''; + const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean); + if (!tags.includes(val)) { + tags.push(val); + state.selectedCard.tags = tags.join(', '); + refreshDrawerTags(); + saveCardEdits(); + } + input.value = ''; + } + + function removeDrawerTag(tag) { + if (!state.selectedCard) return; + const tagsStr = state.selectedCard.tags || ''; + const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t && t !== tag); + state.selectedCard.tags = tags.join(', '); + refreshDrawerTags(); + saveCardEdits(); + } + + function refreshDrawerTags() { + const card = state.selectedCard; + if (!card) return; + const tags = (card.tags || '').split(',').map(t => t.trim()).filter(Boolean); + const tagsEl = document.getElementById('coll-drawer-tags'); + if (tagsEl) { + tagsEl.innerHTML = tags.map(tag => + `${escapeHtml(tag)} + + ` + ).join(''); + } + } + + async function saveCardEdits() { + if (!state.selectedCard) return; + const card = state.selectedCard; + const notesEl = document.getElementById('coll-drawer-notes'); + const finishEl = document.getElementById('coll-drawer-finish'); + const statusEl = document.getElementById('coll-save-status'); + + const payload = { + category: card.category || [], + tags: card.tags || '', + notes: notesEl?.value ?? card.notes ?? '', + finish: finishEl?.value ?? card.finish ?? 'NORMAL', + }; + + if (statusEl) statusEl.textContent = 'Saving…'; + try { + const updated = await patchCard(card.id, payload); + state.selectedCard = { ...card, ...updated }; + if (statusEl) { + statusEl.textContent = 'Saved ✓'; + statusEl.className = 'coll-save-status saved'; + setTimeout(() => { if (statusEl) { statusEl.textContent = ''; statusEl.className = 'coll-save-status'; } }, 2000); + } + // Refresh table row category + refreshTableRow(card.id); + } catch (err) { + if (statusEl) { + statusEl.textContent = `Error: ${err.message}`; + statusEl.className = 'coll-save-status error'; + } + } + } + + function updateCategory(cardId, categories) { + const card = state.items.find(c => c.id === cardId); + if (card) { + card.category = categories; + patchCard(cardId, { category: categories }).catch(err => + console.error('[Collection] updateCategory error:', err) + ); + refreshTableRow(cardId); + } + } + + function refreshTableRow(cardId) { + const row = document.querySelector(`.coll-row[data-id="${cardId}"]`); + if (!row) return; + const card = state.items.find(c => c.id === cardId); + if (!card && state.selectedCard?.id === cardId) { + // update from drawer + const updated = state.selectedCard; + const catTd = row.querySelector('.coll-td-category'); + if (catTd) catTd.innerHTML = renderCategoryChips(updated.category || [], cardId); + return; + } + if (!card) return; + const catTd = row.querySelector('.coll-td-category'); + if (catTd) catTd.innerHTML = renderCategoryChips(card.category || [], cardId); + } + + // ── Category Popover (inline table) ────────────────────── + + let popoverCardId = null; + + function openCategoryPopover(cardId, chipEl) { + popoverCardId = cardId; + const card = state.items.find(c => c.id === cardId); + const categories = Array.isArray(card?.category) ? [...card.category] : []; + + const popover = document.getElementById('coll-cat-popover'); + if (!popover) return; + + const chipRect = chipEl.getBoundingClientRect(); + popover.style.display = 'block'; + popover.style.top = (chipRect.bottom + window.scrollY + 4) + 'px'; + popover.style.left = (chipRect.left + window.scrollX) + 'px'; + + const catChips = categories.map(cat => + `${escapeHtml(cat)} + + ` + ).join(''); + + const catSuggestions = CATEGORY_SUGGESTIONS.filter(s => !categories.includes(s)).map(s => + `` + ).join(''); + + popover.innerHTML = ` +
+ Edit Categories + +
+
${catChips}
+
${catSuggestions}
+
+ + +
`; + } + + function closeCategoryPopover() { + const popover = document.getElementById('coll-cat-popover'); + if (popover) popover.style.display = 'none'; + popoverCardId = null; + } + + function popoverAddCat(cat) { + if (popoverCardId == null) return; + const card = state.items.find(c => c.id === popoverCardId); + if (!card) return; + if (!Array.isArray(card.category)) card.category = []; + if (!card.category.includes(cat)) { + card.category.push(cat); + updateCategory(popoverCardId, card.category); + openCategoryPopover(popoverCardId, document.querySelector(`.coll-row[data-id="${popoverCardId}"] .coll-cat-chip`) || document.getElementById('coll-cat-popover')); + } + } + + function popoverRemoveCat(cat) { + if (popoverCardId == null) return; + const card = state.items.find(c => c.id === popoverCardId); + if (!card) return; + card.category = (card.category || []).filter(c => c !== cat); + updateCategory(popoverCardId, card.category); + // Re-render popover chips + const chipsEl = document.getElementById('coll-popover-chips'); + if (chipsEl) { + chipsEl.innerHTML = card.category.map(c => + `${escapeHtml(c)} + + ` + ).join(''); + } + } + + function popoverAddCatFromInput() { + const input = document.getElementById('coll-popover-input'); + if (!input) return; + const val = input.value.trim(); + if (val) { + popoverAddCat(val); + input.value = ''; + } + } + + // ── Import Flow ────────────────────────────────────────── + + function openImport() { + state.importStep = 1; + state.importSource = ''; + state.importContent = ''; + state.importMapping = {}; + state.importHeaders = []; + state.importMode = 'MERGE'; + state.importMissingFinishNormal = true; + const modal = document.getElementById('coll-import-modal'); + if (modal) modal.classList.add('active'); + renderImportStep(); + } + + function closeImport() { + const modal = document.getElementById('coll-import-modal'); + if (modal) modal.classList.remove('active'); + state.importStep = 0; + } + + function importNext() { + if (state.importStep === 1) { + if (!state.importSource) { + alert('Please select a source type.'); + return; + } + state.importStep = 2; + } else if (state.importStep === 2) { + if (!state.importContent.trim()) { + alert('Please provide import content.'); + return; + } + if (state.importSource !== 'TEXT') { + // Parse CSV headers + const lines = state.importContent.split('\n'); + if (lines.length > 0) { + state.importHeaders = parseCSVLine(lines[0]); + buildDefaultMapping(); + } + state.importStep = 3; + } else { + state.importStep = 4; + } + } else if (state.importStep === 3) { + state.importStep = 4; + } + renderImportStep(); + } + + function importPrev() { + if (state.importStep > 1) { + if (state.importStep === 4 && state.importSource !== 'TEXT') { + state.importStep = 3; + } else if (state.importStep === 4 && state.importSource === 'TEXT') { + state.importStep = 2; + } else { + state.importStep--; + } + renderImportStep(); + } + } + + function parseCSVLine(line) { + const result = []; + let cur = ''; + let inQuote = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + inQuote = !inQuote; + } else if (ch === ',' && !inQuote) { + result.push(cur.trim()); + cur = ''; + } else { + cur += ch; + } + } + result.push(cur.trim()); + return result; + } + + function buildDefaultMapping() { + const preset = state.importSource === 'MOXFIELD' ? MOXFIELD_DEFAULTS + : state.importSource === 'ARCHIDEKT' ? ARCHIDEKT_DEFAULTS + : {}; + state.importMapping = {}; + state.importHeaders.forEach(header => { + state.importMapping[header] = preset[header] || 'IGNORE'; + }); + } + + function renderImportStep() { + const body = document.getElementById('coll-import-body'); + if (!body) return; + + const stepLabels = ['', 'Source', 'Content', 'Mapping', 'Options']; + const steps = [1,2,3,4].filter(n => !(n === 3 && state.importSource === 'TEXT')); + const stepIndicator = steps.map(n => { + const active = n === state.importStep ? ' active' : ''; + const done = n < state.importStep ? ' done' : ''; + return `${n}`; + }).join('—'); + + let content = ''; + if (state.importStep === 1) content = renderImportStep1(); + if (state.importStep === 2) content = renderImportStep2(); + if (state.importStep === 3) content = renderImportStep3(); + if (state.importStep === 4) content = renderImportStep4(); + + const showPrev = state.importStep > 1; + const isLastStep = state.importStep === 4; + + body.innerHTML = ` +
+

Import Cards — Step ${state.importStep}: ${stepLabels[state.importStep]}

+ +
+
${stepIndicator}
+
${content}
+ +
`; + } + + function renderImportStep1() { + const sources = [ + { value: 'GENERIC_CSV', label: 'Generic CSV', desc: 'Any CSV with column mapping' }, + { value: 'MOXFIELD', label: 'Moxfield CSV', desc: 'Moxfield export format (auto-mapped)' }, + { value: 'ARCHIDEKT', label: 'Archidekt CSV',desc: 'Archidekt export format (auto-mapped)' }, + { value: 'TEXT', label: 'Plain Text', desc: '1 Card Name (SET) per line' }, + ]; + return `
+ ${sources.map(s => { + const checked = state.importSource === s.value ? 'checked' : ''; + return ``; + }).join('')} +
`; + } + + function renderImportStep2() { + if (state.importSource === 'TEXT') { + return `
+ + +
Formats: 3 Card Name (SET) · 1 Card Name (SET) 123 · Card Name
+
`; + } + return `
+ +
+ +
+ ${state.importContent + ? `✓ File loaded (${state.importContent.split('\n').length} lines)` + : `Click to select or drag & drop a CSV file`} +
+
+ ${state.importContent + ? `
+
Preview (first 3 lines):
+
${escapeHtml(state.importContent.split('\n').slice(0,3).join('\n'))}
+
` + : ''} +
`; + } + + function renderImportStep3() { + if (state.importHeaders.length === 0) { + return `

No headers detected. Please go back and check your file.

`; + } + + const fieldOptsHtml = FIELD_OPTIONS.map(o => + `` + ).join(''); + + const rows = state.importHeaders.map(header => { + const currentVal = state.importMapping[header] || 'IGNORE'; + const opts = FIELD_OPTIONS.map(o => + `` + ).join(''); + return ` + ${escapeHtml(header)} + + + + `; + }).join(''); + + return ` +
Map CSV columns to collection fields. Pre-filled for ${escapeHtml(state.importSource)} format.
+ + + ${rows} +
CSV ColumnMaps To
`; + } + + function renderImportStep4() { + return ` +
+
+
Import Mode
+ + +
+
+ +
+ ${state.importMode === 'REPLACE' + ? `
⚠ Replace mode will permanently delete your existing collection before importing.
` + : ''} +
`; + } + + function setImportSource(source) { + state.importSource = source; + // re-render step 1 to update selected state + const body = document.getElementById('coll-import-body'); + if (body) renderImportStep(); + } + + function setImportContent(content) { + state.importContent = content; + } + + function setImportMapping(header, field) { + state.importMapping[header] = field; + } + + function setImportMode(mode) { + state.importMode = mode; + renderImportStep(); + } + + function setMissingFinishNormal(val) { + state.importMissingFinishNormal = val; + } + + function handleFileSelect(file) { + if (!file) return; + const reader = new FileReader(); + reader.onload = e => { + state.importContent = e.target.result; + renderImportStep(); + }; + reader.onerror = () => alert('Failed to read file.'); + reader.readAsText(file); + } + + async function submitImport() { + const statusEl = document.getElementById('coll-import-status'); + if (statusEl) statusEl.innerHTML = `
Importing…
`; + + const payload = { + source: state.importSource, + mode: state.importMode, + content: state.importContent, + mapping: state.importMapping || {}, + }; + + try { + const res = await fetch(`${API_BASE}/api/collection/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + + if (!res.ok) { + let msg = data.detail || data.message || `HTTP ${res.status}`; + if (typeof msg === 'object') msg = JSON.stringify(msg); + throw new Error(msg); + } + + const { importedCount = 0, updatedCount = 0, failedCount = 0, errors = [] } = data; + + let html = `
+
✓ Import complete
+
${importedCount} added · ${updatedCount} updated · ${failedCount} failed
+
`; + + if (errors.length > 0) { + html += `
+
Errors (${errors.length}):
+
    ${errors.slice(0, 10).map(e => `
  • ${escapeHtml(e)}
  • `).join('')}
+ ${errors.length > 10 ? `
…and ${errors.length - 10} more
` : ''} +
`; + } + + html += ``; + if (statusEl) statusEl.innerHTML = html; + + // Hide footer nav buttons + const footer = document.querySelector('.coll-import-footer'); + if (footer) footer.style.display = 'none'; + + // Refresh collection + applyColumnVisibility(); + await fetchCollection(); + + } catch (err) { + if (statusEl) { + const msg = (err && err.message) ? err.message : String(err); + statusEl.innerHTML = `
✗ Import failed: ${escapeHtml(msg)}
`; + } + } + } + + // ── Export Flow ────────────────────────────────────────── + + function openExport() { + state.exportOpen = true; + const modal = document.getElementById('coll-export-modal'); + if (modal) modal.classList.add('active'); + renderExportModal(); + } + + function closeExport() { + state.exportOpen = false; + const modal = document.getElementById('coll-export-modal'); + if (modal) modal.classList.remove('active'); + } + + function renderExportModal() { + const body = document.getElementById('coll-export-body'); + if (!body) return; + body.innerHTML = ` +
+

Export Collection

+ +
+
+
+ + +
+
+ +
+
+
Total collection: ${state.total} cards
+
+
+ `; + } + + function submitExport() { + const formatEl = document.getElementById('coll-export-format'); + const filteredEl = document.getElementById('coll-export-filtered'); + const format = formatEl?.value || 'INTERNAL_CSV'; + const useFilters = filteredEl?.checked || false; + + const params = new URLSearchParams(); + params.set('format', format); + + if (useFilters) { + const f = state.filters; + if (state.search) params.set('q', state.search); + if (f.colors.length) params.set('colors', f.colors.join(',')); + if (f.types.length) params.set('types', f.types.join(',')); + if (f.finish) params.set('finish', f.finish); + if (f.category.length) params.set('category', f.category.join(',')); + if (f.isLegendary) params.set('isLegendary', 'true'); + if (f.isBasic) params.set('isBasic', 'true'); + if (f.isGameChanger) params.set('isGameChanger', 'true'); + if (f.highSalt) params.set('highSalt', 'true'); + if (f.cmcMin != null) params.set('cmcMin', f.cmcMin); + if (f.cmcMax != null) params.set('cmcMax', f.cmcMax); + if (f.priceMin != null) params.set('priceMin', f.priceMin); + if (f.priceMax != null) params.set('priceMax', f.priceMax); + } + + const url = `${API_BASE}/api/collection/export?${params.toString()}`; + const a = document.createElement('a'); + a.href = url; + a.download = `collection_${format.toLowerCase()}_${new Date().toISOString().slice(0,10)}.${format === 'TEXT' ? 'txt' : 'csv'}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + closeExport(); + } + + // ── Re-enrich ──────────────────────────────────────────── + + async function reEnrich() { + const btn = document.querySelector('.coll-reenrich-btn'); + if (!btn) return; + const originalText = btn.textContent; + btn.disabled = true; + btn.textContent = 'Refreshing...'; + + try { + const res = await fetch(`${API_BASE}/api/collection/re-enrich`, { method: 'POST' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const msg = `Refreshed ${data.enrichedCount} of ${data.total} cards` + + (data.skippedCount > 0 ? ` (${data.skippedCount} skipped)` : ''); + alert(msg); + fetchCollection(); // reload table + } catch (err) { + alert(`Re-enrich failed: ${err.message}`); + } finally { + btn.disabled = false; + btn.textContent = originalText; + } + } + + // ── Auto-Classify ──────────────────────────────────────── + + async function autoClassify(forceAll = false) { + const btn = document.querySelector('.coll-autoclassify-btn'); + if (!btn) return; + const originalText = btn.textContent; + btn.disabled = true; + btn.textContent = 'Classifying...'; + + try { + const endpoint = forceAll + ? `${API_BASE}/api/collection/auto-classify-all` + : `${API_BASE}/api/collection/auto-classify`; + const res = await fetch(endpoint, { method: 'POST' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const msg = `Classified ${data.classifiedCount} of ${data.total} cards` + + (data.skippedCount > 0 ? ` (${data.skippedCount} already had categories)` : ''); + alert(msg); + fetchCollection(); // reload table + } catch (err) { + alert(`Auto-classify failed: ${err.message}`); + } finally { + btn.disabled = false; + btn.textContent = originalText; + } + } + + // ── Navigation ─────────────────────────────────────────── + + function goBack() { + window.location.href = '/index.html'; + } + + // ── Bulk Row Selection & Quantity Edit ───────────────────── + function toggleRowSelect(cardId, checked) { + if (checked) { + state.selectedRows.add(cardId); + } else { + state.selectedRows.delete(cardId); + } + updateBulkBar(); + } + + function toggleSelectAll(checked) { + if (checked) { + state.items.forEach(c => state.selectedRows.add(c.id)); + } else { + state.selectedRows.clear(); + } + // Update all checkboxes + document.querySelectorAll('.coll-row-check').forEach(cb => { + cb.checked = checked; + }); + updateBulkBar(); + } + + function selectAllRows() { + state.items.forEach(c => state.selectedRows.add(c.id)); + document.querySelectorAll('.coll-row-check').forEach(cb => cb.checked = true); + const selectAllCb = document.getElementById('coll-select-all'); + if (selectAllCb) selectAllCb.checked = true; + updateBulkBar(); + } + + function clearSelection() { + state.selectedRows.clear(); + document.querySelectorAll('.coll-row-check').forEach(cb => cb.checked = false); + const selectAllCb = document.getElementById('coll-select-all'); + if (selectAllCb) selectAllCb.checked = false; + updateBulkBar(); + } + + function updateBulkBar() { + const bar = document.getElementById('coll-bulk-bar'); + const countEl = document.getElementById('coll-bulk-count'); + const n = state.selectedRows.size; + if (bar) bar.style.display = n > 0 ? 'flex' : 'none'; + if (countEl) countEl.textContent = `${n} selected`; + } + + async function bulkAdjustQuantity(delta) { + if (state.selectedRows.size === 0) return; + const ids = [...state.selectedRows]; + const btn = event.target; + if (btn) btn.disabled = true; + let successCount = 0; + for (const id of ids) { + const card = state.items.find(c => c.id === id); + if (!card) continue; + const newQty = Math.max(0, (card.quantity ?? 1) + delta); + try { + await patchCard(id, { quantity: newQty }); + card.quantity = newQty; + successCount++; + } catch (e) { + console.warn('[Bulk] Failed to update card', id, e); + } + } + if (btn) btn.disabled = false; + renderTable(); + applyColumnVisibility(); + } + + async function bulkSetQuantity() { + if (state.selectedRows.size === 0) return; + const input = document.getElementById('coll-bulk-qty-input'); + const newQty = input ? Math.max(0, parseInt(input.value, 10) || 0) : 0; + const ids = [...state.selectedRows]; + const btn = document.querySelector('.coll-bulk-set-btn'); + if (btn) btn.disabled = true; + for (const id of ids) { + const card = state.items.find(c => c.id === id); + if (!card) continue; + try { + await patchCard(id, { quantity: newQty }); + card.quantity = newQty; + } catch (e) { + console.warn('[Bulk] Failed to set card', id, e); + } + } + if (btn) btn.disabled = false; + renderTable(); + applyColumnVisibility(); + } + + // ── Event Binding ──────────────────────────────────────── + + function bindEvents() { + // Search input with debounce + const searchEl = document.getElementById('coll-search'); + if (searchEl) { + searchEl.addEventListener('input', e => { + clearTimeout(searchDebounceTimer); + searchDebounceTimer = setTimeout(() => { + state.search = e.target.value.trim(); + state.page = 1; + pushUrlState(); + fetchCollection(); + }, DEBOUNCE_MS); + }); + searchEl.addEventListener('keydown', e => { + if (e.key === 'Escape') { + searchEl.value = ''; + state.search = ''; + state.page = 1; + pushUrlState(); + fetchCollection(); + } + }); + } + + // Close drawer on Escape + document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + if (state.drawerOpen) closeDrawer(); + if (state.importStep > 0) closeImport(); + if (state.exportOpen) closeExport(); + closeCategoryPopover(); + } + }); + + // Close popover on outside click + document.addEventListener('click', e => { + const popover = document.getElementById('coll-cat-popover'); + if (popover && popover.style.display !== 'none') { + if (!popover.contains(e.target) && !e.target.classList.contains('coll-cat-chip')) { + closeCategoryPopover(); + } + } + // Close set/keyword dropdowns on outside click + const setDD = document.getElementById('coll-set-dropdown'); + const setInput = document.getElementById('coll-set-input'); + if (setDD && setDD.style.display !== 'none' && !setDD.contains(e.target) && e.target !== setInput) { + setDD.innerHTML = ''; setDD.style.display = 'none'; + } + const kwDD = document.getElementById('coll-kw-dropdown'); + const kwInput = document.getElementById('coll-kw-input'); + if (kwDD && kwDD.style.display !== 'none' && !kwDD.contains(e.target) && e.target !== kwInput) { + kwDD.innerHTML = ''; kwDD.style.display = 'none'; + } + }); + + // File drop zone + const dropZone = document.getElementById('coll-file-drop'); + if (dropZone) { + dropZone.addEventListener('dragover', e => { + e.preventDefault(); + dropZone.classList.add('drag-over'); + }); + dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); + dropZone.addEventListener('drop', e => { + e.preventDefault(); + dropZone.classList.remove('drag-over'); + const file = e.dataTransfer.files[0]; + if (file) handleFileSelect(file); + }); + } + } + + // ── Card Scanner ───────────────────────────────────────── + + let scanState = { + file: null, + previewUrl: null, + mode: 'single', + scanning: false, + results: [], // ScanResult dicts from API + selected: new Set(), // indices of results selected for add + }; + + function openScan() { + scanState = { file: null, previewUrl: null, mode: 'single', + scanning: false, results: [], selected: new Set(), + manualCards: [] }; + const modal = document.getElementById('coll-scan-modal'); + if (modal) modal.classList.add('active'); + renderScanModal(); + } + + function closeScan() { + const modal = document.getElementById('coll-scan-modal'); + if (modal) modal.classList.remove('active'); + // Revoke object URL to free memory + if (scanState.previewUrl) { + URL.revokeObjectURL(scanState.previewUrl); + scanState.previewUrl = null; + } + } + + function renderScanModal() { + const body = document.getElementById('coll-scan-body'); + if (!body) return; + + const hasResults = scanState.results.length > 0; + const hasFile = scanState.file !== null; + + let content = ''; + + if (hasResults) { + content = renderScanResults(); + } else { + content = renderScanUpload(); + } + + body.innerHTML = ` +
+

Scan from Image

+ +
+
${content}
+
`; + } + + function renderScanUpload() { + const preview = scanState.previewUrl + ? 'Preview' + : ''; + + const singleChk = scanState.mode === 'single' ? 'checked' : ''; + const multiChk = scanState.mode === 'multi' ? 'checked' : ''; + const scanDisabled = scanState.file ? '' : 'disabled'; + const scanningCls = scanState.scanning ? ' scanning' : ''; + + return ` +
+
+ ${preview || '
Drop card image here or click to browse
'} + +
+
+
+ + +
+
+ Single: one card per photo. Multi: detects multiple cards in one image. +
+
+
+ `; + } + + function renderScanResults() { + const results = scanState.results; + const selectedCount = scanState.selected.size; + const manualCount = scanState.manualCards ? scanState.manualCards.length : 0; + const totalAdd = selectedCount + manualCount; + + const rows = results.map((r, i) => { + const checked = scanState.selected.has(i) ? 'checked' : ''; + const confCls = 'coll-scan-conf-' + (r.confidence || 'low'); + const confLabel = (r.confidence || 'low').charAt(0).toUpperCase() + (r.confidence || 'low').slice(1); + + if (r.error && !r.matched_name) { + return `
+
+ ${escapeHtml(r.error)} +
+ + +
+
+
`; + } + + const imgHtml = r.image_uri + ? '' + escapeAttr(r.matched_name) + '' + : '
No image
'; + + return `
+ + ${imgHtml} +
+
${escapeHtml(r.matched_name || r.raw_ocr)}
+
+ ${confLabel} + ${r.set_code ? ' · ' + escapeHtml(r.set_code.toUpperCase()) : ''} + ${r.collector_number ? ' #' + escapeHtml(r.collector_number) : ''} + ${r.rarity ? ' · ' + escapeHtml(r.rarity.charAt(0).toUpperCase() + r.rarity.slice(1)) : ''} +
+
+
+ + +
+
`; + }).join(''); + + // Manual cards added via name lookup + const manualRows = (scanState.manualCards || []).map((mc, mi) => { + return `
+ + ${mc.image_uri + ? '' + escapeAttr(mc.name) + '' + : '
No image
'} +
+
${escapeHtml(mc.name)}
+
+ Manual + ${mc.set_code ? ' · ' + escapeHtml(mc.set_code.toUpperCase()) : ''} +
+
+
+ + +
+ +
`; + }).join(''); + + return ` +
+
+ ${results.length} card${results.length !== 1 ? 's' : ''} detected + + +
+
+ ${rows} + ${manualRows} +
+
+ + +
+
+ `; + } + + function handleScanFile(input) { + const file = input.files && input.files[0]; + if (!file) return; + _setScanFile(file); + } + + function _setScanFile(file) { + if (scanState.previewUrl) URL.revokeObjectURL(scanState.previewUrl); + scanState.file = file; + scanState.previewUrl = URL.createObjectURL(file); + scanState.results = []; + scanState.selected = new Set(); + renderScanModal(); + } + + function setScanMode(mode) { + scanState.mode = mode; + } + + async function submitScan() { + if (!scanState.file || scanState.scanning) return; + scanState.scanning = true; + renderScanModal(); + + const formData = new FormData(); + formData.append('file', scanState.file); + formData.append('mode', scanState.mode); + + try { + const resp = await fetch(API_BASE + '/api/collection/scan', { + method: 'POST', + body: formData, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({ detail: resp.statusText })); + throw new Error(err.detail || resp.statusText); + } + const data = await resp.json(); + scanState.results = data.results || []; + // Auto-select all high/medium confidence matches + scanState.selected = new Set(); + scanState.results.forEach((r, i) => { + if (r.matched_name && (r.confidence === 'high' || r.confidence === 'medium')) { + scanState.selected.add(i); + } + }); + } catch (e) { + const statusEl = document.getElementById('coll-scan-status'); + if (statusEl) statusEl.innerHTML = '
Scan failed: ' + escapeHtml(e.message) + '
'; + } finally { + scanState.scanning = false; + renderScanModal(); + } + } + + function toggleScanResult(idx) { + if (scanState.selected.has(idx)) { + scanState.selected.delete(idx); + } else { + scanState.selected.add(idx); + } + renderScanModal(); + } + + function scanSelectAll() { + scanState.results.forEach((r, i) => { + if (r.matched_name) scanState.selected.add(i); + }); + renderScanModal(); + } + + function scanDeselectAll() { + scanState.selected.clear(); + renderScanModal(); + } + + function scanBack() { + scanState.results = []; + scanState.selected = new Set(); + scanState.manualCards = []; + renderScanModal(); + } + + async function scanManualLookup(errIdx) { + const input = document.querySelector('input.coll-scan-manual-input[data-err-idx="' + errIdx + '"]'); + if (!input || !input.value.trim()) return; + const name = input.value.trim(); + input.disabled = true; + + try { + const lookupResp = await fetch('https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name)); + if (!lookupResp.ok) { + const err = await lookupResp.json().catch(() => ({})); + throw new Error(err.details || 'Card not found on Scryfall'); + } + const card = await lookupResp.json(); + const imageUris = card.image_uris || (card.card_faces && card.card_faces[0] ? card.card_faces[0].image_uris : {}) || {}; + + // Replace the error result with a matched result + scanState.results[errIdx] = { + raw_ocr: name, + matched_name: card.name, + set_code: card.set || '', + scryfall_id: card.id || '', + confidence: 'high', + image_uri: imageUris.normal || imageUris.small || '', + error: '', + }; + scanState.selected.add(errIdx); + renderScanModal(); + } catch (e) { + input.disabled = false; + input.style.borderColor = 'var(--lab-danger, #f85149)'; + input.placeholder = e.message || 'Not found, try again'; + input.value = ''; + } + } + + async function scanManualAdd() { + const input = document.getElementById('coll-scan-manual-name'); + if (!input || !input.value.trim()) return; + const name = input.value.trim(); + input.disabled = true; + + try { + const lookupResp = await fetch('https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name)); + if (!lookupResp.ok) { + const err = await lookupResp.json().catch(() => ({})); + throw new Error(err.details || 'Card not found on Scryfall'); + } + const card = await lookupResp.json(); + const imageUris = card.image_uris || (card.card_faces && card.card_faces[0] ? card.card_faces[0].image_uris : {}) || {}; + + if (!scanState.manualCards) scanState.manualCards = []; + scanState.manualCards.push({ + name: card.name, + set_code: card.set || '', + image_uri: imageUris.normal || imageUris.small || '', + }); + renderScanModal(); + } catch (e) { + input.disabled = false; + input.style.borderColor = 'var(--lab-danger, #f85149)'; + input.placeholder = e.message || 'Not found, try again'; + input.value = ''; + } + } + + function scanRemoveManual(idx) { + if (scanState.manualCards) { + scanState.manualCards.splice(idx, 1); + renderScanModal(); + } + } + + async function submitScanAdd() { + const cards = []; + // OCR-matched cards + scanState.selected.forEach(idx => { + const r = scanState.results[idx]; + if (!r || !r.matched_name) return; + const qtyInput = document.querySelector('input.coll-scan-qty-input[data-scan-idx="' + idx + '"]'); + const qty = qtyInput ? parseInt(qtyInput.value, 10) || 1 : 1; + cards.push({ + name: r.matched_name, + set_code: r.set_code || '', + collector_number: r.collector_number || '', + quantity: qty, + }); + }); + // Manual cards + (scanState.manualCards || []).forEach((mc, mi) => { + const qtyInput = document.querySelector('input.coll-scan-manual-qty[data-manual-idx="' + mi + '"]'); + const qty = qtyInput ? parseInt(qtyInput.value, 10) || 1 : 1; + cards.push({ + name: mc.name, + set_code: mc.set_code || '', + quantity: qty, + }); + }); + + if (cards.length === 0) return; + + const statusEl = document.getElementById('coll-scan-status'); + if (statusEl) statusEl.innerHTML = '
Adding cards...
'; + + try { + const resp = await fetch(API_BASE + '/api/collection/scan/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cards }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({ detail: resp.statusText })); + throw new Error(err.detail || resp.statusText); + } + const data = await resp.json(); + let msg = ''; + if (data.importedCount) msg += data.importedCount + ' added'; + if (data.updatedCount) msg += (msg ? ', ' : '') + data.updatedCount + ' updated'; + if (data.failedCount) msg += (msg ? ', ' : '') + data.failedCount + ' failed'; + if (statusEl) statusEl.innerHTML = '
' + escapeHtml(msg || 'Done') + '
'; + + // Refresh collection after short delay + setTimeout(() => { + closeScan(); + fetchCollection(); + }, 1500); + } catch (e) { + if (statusEl) statusEl.innerHTML = '
Failed: ' + escapeHtml(e.message) + '
'; + } + } + + // Bind scan dropzone click + drag events after render + function _bindScanDropzone() { + const dropzone = document.getElementById('coll-scan-dropzone'); + if (!dropzone) return; + dropzone.addEventListener('click', () => { + const input = document.getElementById('coll-scan-file'); + if (input) input.click(); + }); + dropzone.addEventListener('dragover', e => { + e.preventDefault(); + dropzone.classList.add('drag-over'); + }); + dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over')); + dropzone.addEventListener('drop', e => { + e.preventDefault(); + dropzone.classList.remove('drag-over'); + const file = e.dataTransfer.files[0]; + if (file) _setScanFile(file); + }); + } + + // Patch renderScanModal to rebind dropzone after DOM update + const _origRenderScanModal = renderScanModal; + renderScanModal = function() { + _origRenderScanModal(); + setTimeout(_bindScanDropzone, 0); + }; + + // ── Public API ─────────────────────────────────────────── + return { + init, + goBack, + // Import + openImport, + closeImport, + importNext, + importPrev, + submitImport, + setImportSource, + setImportContent, + setImportMapping, + setImportMode, + setMissingFinishNormal, + handleFileSelect, + // Export + openExport, + closeExport, + submitExport, + // Scan + openScan, + closeScan, + handleScanFile, + setScanMode, + submitScan, + toggleScanResult, + scanSelectAll, + scanDeselectAll, + scanBack, + submitScanAdd, + scanManualLookup, + scanManualAdd, + scanRemoveManual, + // Drawer + openDrawer, + closeDrawer, + switchDrawerTab, + loadEdhrecTab, + saveCardEdits, + addDrawerCategory, + removeDrawerCategory, + addDrawerCategoryFromInput, + addDrawerTagFromInput, + removeDrawerTag, + // Filters + toggleColor, + toggleType, + toggleFilter, + setFinish, + setCmcRange, + setPriceRange, + toggleCategoryFilter, + toggleRarity, + setPowerRange, + setToughRange, + setEdhrecRange, + setQtyRange, + onSetInput, + addSetFilter, + removeSetFilter, + onKwInput, + addKeywordFilter, + removeKeywordFilter, + resetFilters, + // Sort / page + setSort, + setSortFromBar, + toggleSortDir, + setPage, + setPageSize, + // Category inline + updateCategory, + openCategoryPopover, + closeCategoryPopover, + popoverAddCat, + popoverRemoveCat, + popoverAddCatFromInput, + reEnrich, + autoClassify, + toggleStats, + // Column visibility + toggleColumn, + openColumnPanel, + closeColumnPanel, + resetColumns, + // Bulk selection & quantity + toggleRowSelect, + toggleSelectAll, + selectAllRows, + clearSelection, + bulkAdjustQuantity, + bulkSetQuantity, + }; +})(); diff --git a/lan-client/deckbuilder.css b/lan-client/deckbuilder.css new file mode 100644 index 0000000..223f671 --- /dev/null +++ b/lan-client/deckbuilder.css @@ -0,0 +1,3286 @@ +/* ═══════════════════════════════════════════════════════════ + Commander AI Lab — Deck Builder Styles + Uses deck- prefix to avoid conflicts with lab- / coll- classes + Tokens from global.css design system + ═══════════════════════════════════════════════════════════ */ + +/* ── JS Backward-Compat Aliases ───────────────────────── + deckbuilder.js uses var(--lab-danger) in template literals. + Map it to the global token so inline styles work. + ──────────────────────────────────────────────────────── */ +:root { + --lab-danger: var(--error); +} + +/* ── Z-INDEX STACK ──────────────────────────────────────── + 10 sticky section headers + 50 quickadd dropdown + 60 card preview tooltip + 100 topbar + 500 toast container + 1000 modal overlays + ──────────────────────────────────────────────────────── */ + +/* ── 1. Page Base ───────────────────────────────────────── */ + +#deck-builder-page { + min-height: 100vh; + background: var(--bg); + color: var(--text); + display: flex; + flex-direction: column; + isolation: isolate; +} + +/* ── 2. Top Bar ─────────────────────────────────────────── */ + +.deck-topbar { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: var(--sp-4); + position: sticky; + top: var(--nav-height); + background: rgba(12, 14, 20, 0.97); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-bottom: 1px solid var(--border); + z-index: 100; + padding: var(--sp-3) var(--sp-5); + min-height: 60px; +} + +.deck-topbar-commander { + display: flex; + align-items: center; + gap: var(--sp-3); + flex-shrink: 0; + min-width: 0; +} + +.deck-commander-portrait { + width: 44px; + height: 60px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--surface-1); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; + position: relative; + cursor: pointer; + transition: border-color var(--duration) var(--ease); +} + +.deck-commander-portrait:hover { + border-color: var(--accent); +} + +.deck-commander-portrait-placeholder { + font-size: 20px; + opacity: 0.4; + user-select: none; +} + +.deck-commander-portrait-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.deck-name-block { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.deck-name-input { + background: transparent; + border: none; + border-bottom: 1px solid transparent; + color: #fff; + font-size: 16px; + font-weight: 600; + font-family: inherit; + padding: 2px 4px 2px 0; + outline: none; + width: 200px; + transition: border-color var(--duration) var(--ease); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.deck-name-input:hover { + border-bottom-color: var(--border); +} + +.deck-name-input:focus { + border-bottom-color: var(--accent); +} + +.deck-name-input::placeholder { + color: var(--text-secondary); + font-style: italic; +} + +.deck-commander-label { + font-size: 11px; + color: var(--accent); + padding-left: var(--sp-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; + cursor: pointer; + transition: color 0.1s; +} + +.deck-commander-label:hover { + color: var(--text); +} + +.deck-color-chips { + display: flex; + gap: var(--sp-1); + flex-shrink: 0; +} + +/* MTG mana color chips — domain-specific colors */ +.deck-color-chip { + width: 20px; + height: 20px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: 800; + line-height: 1; + box-shadow: var(--shadow-sm); + border: 1.5px solid rgba(0, 0, 0, 0.3); + user-select: none; +} + +.deck-chip-W { background: #F9FAF4; color: #333; } +.deck-chip-U { background: #0E68AB; color: #fff; } +.deck-chip-B { background: #3a2a1a; color: #c9a060; border-color: #5a3a1a; } +.deck-chip-R { background: #D3202A; color: #fff; } +.deck-chip-G { background: #00733E; color: #fff; } +.deck-chip-C { background: #9a9a9a; color: #fff; } + +.deck-topbar-center { + display: flex; + align-items: center; + gap: var(--sp-3); + flex-grow: 1; + justify-content: center; + min-width: 0; +} + +.deck-count-display { + display: flex; + align-items: baseline; + gap: 3px; + font-size: 13px; + color: var(--text-secondary); + flex-shrink: 0; +} + +.deck-count-num { + font-size: 20px; + font-weight: 700; + color: var(--text); + line-height: 1; +} + +.deck-count-num.over-limit { + color: var(--error); +} + +.deck-count-num.at-limit { + color: var(--success); +} + +.deck-count-sep { + font-size: 14px; +} + +.deck-count-target { + font-size: 14px; + color: var(--text-secondary); +} + +.deck-count-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.deck-selector { + background: var(--surface-1); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 6px var(--sp-3); + font-size: 13px; + font-family: inherit; + outline: none; + cursor: pointer; + max-width: 200px; + transition: border-color var(--duration) var(--ease); +} + +.deck-selector:hover, +.deck-selector:focus { + border-color: var(--accent); +} + +.deck-selector-wrap { + flex-shrink: 0; +} + +.deck-nav-links { + display: flex; + gap: var(--sp-2); + flex-shrink: 0; +} + +.deck-nav-link { + color: var(--text-secondary); + font-size: 12px; + text-decoration: none; + padding: 5px var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-1); + transition: all var(--duration) var(--ease); + white-space: nowrap; +} + +.deck-nav-link:hover { + color: var(--accent); + border-color: var(--accent); +} + +.deck-topbar-actions { + display: flex; + align-items: center; + gap: var(--sp-2); + flex-shrink: 0; +} + +/* ── 3. Shared Button Styles ────────────────────────────── */ + +.deck-btn { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + padding: 7px 14px; + border-radius: var(--radius-md); + font-size: 13px; + font-family: inherit; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--border); + transition: all var(--duration) var(--ease); + white-space: nowrap; + user-select: none; +} + +.deck-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; +} + +.deck-btn-ghost { + background: var(--surface-1); + color: var(--text); + border-color: var(--border); +} + +.deck-btn-ghost:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.deck-btn-accent { + background: var(--accent-dim); + color: var(--accent); + border-color: var(--accent-glow); +} + +.deck-btn-accent:hover:not(:disabled) { + background: rgba(91, 158, 240, 0.28); + border-color: var(--accent); +} + +.deck-btn-accent-sm { + background: var(--accent-dim); + color: var(--accent); + border-color: var(--accent-glow); + padding: 5px var(--sp-3); + font-size: 12px; +} + +.deck-btn-accent-sm:hover:not(:disabled) { + background: rgba(91, 158, 240, 0.28); + border-color: var(--accent); +} + +.deck-btn-success { + background: var(--success-dim); + color: var(--success); + border-color: rgba(74, 222, 128, 0.4); +} + +.deck-btn-success:hover:not(:disabled) { + background: rgba(74, 222, 128, 0.25); + border-color: var(--success); +} + +.deck-btn-danger { + background: var(--error-dim); + color: var(--error); + border-color: rgba(248, 113, 113, 0.3); +} + +.deck-btn-danger:hover:not(:disabled) { + background: rgba(248, 113, 113, 0.22); + border-color: var(--error); +} + +.deck-btn-import { + background: var(--success-dim); + color: var(--success); + border-color: rgba(74, 222, 128, 0.4); + font-weight: 600; +} + +.deck-btn-import:hover:not(:disabled) { + background: rgba(74, 222, 128, 0.3); + border-color: var(--success); + box-shadow: 0 0 10px var(--success-dim); +} + +.deck-btn-sim { + background: linear-gradient(135deg, var(--accent-glow), var(--purple-dim)); + color: var(--accent); + border-color: rgba(91, 158, 240, 0.5); + font-weight: 600; +} + +.deck-btn-sim:hover:not(:disabled) { + background: linear-gradient(135deg, rgba(91, 158, 240, 0.38), rgba(167, 139, 250, 0.3)); + border-color: var(--accent); + box-shadow: 0 0 12px var(--accent-glow); +} + +.deck-btn-danger { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + border-color: rgba(239, 68, 68, 0.4); +} +.deck-btn-danger:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.3); + border-color: #ef4444; + box-shadow: 0 0 10px rgba(239, 68, 68, 0.2); +} + +.deck-btn-danger-outline { + background: transparent; + color: rgba(239, 68, 68, 0.7); + border-color: rgba(239, 68, 68, 0.3); + font-size: 0.72rem; +} +.deck-btn-danger-outline:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.12); + color: #ef4444; + border-color: rgba(239, 68, 68, 0.5); +} + +/* Import modal */ +.deck-import-tabs { + display: flex; + gap: 0; + margin-bottom: var(--sp-3); + border-bottom: 1px solid var(--border); +} + +.deck-import-tab { + background: none; + border: none; + color: var(--text-secondary); + font-size: 13px; + padding: var(--sp-2) var(--sp-4); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all var(--duration) var(--ease); +} + +.deck-import-tab:hover { color: var(--text); } + +.deck-import-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +#deck-import-textarea { + width: 100%; + background: var(--surface-1); + border: 1px solid var(--border); + color: var(--text); + padding: var(--sp-3); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 13px; + resize: vertical; + outline: none; +} + +#deck-import-textarea:focus { border-color: var(--accent); } + +.deck-import-dropzone { + border: 2px dashed var(--border); + border-radius: var(--radius-md); + padding: var(--sp-8) var(--sp-5); + text-align: center; + cursor: pointer; + transition: all var(--duration) var(--ease); +} + +.deck-import-dropzone:hover, +.deck-import-dropzone.dragover { + border-color: var(--accent); + background: var(--accent-dim); +} + +.deck-import-dropzone-label { + color: var(--text-secondary); + font-size: 13px; +} + +.deck-import-options { + display: flex; + gap: var(--sp-4); + margin-top: var(--sp-3); + flex-wrap: wrap; +} + +.deck-import-option-label { + font-size: 13px; + color: var(--text); + display: flex; + align-items: center; + gap: var(--sp-2); + cursor: pointer; +} + +.deck-import-option-label input[type="radio"] { + accent-color: var(--accent); +} + +.deck-import-name-row { + margin-top: var(--sp-3); +} + +.deck-import-name-row label { + font-size: 12px; + color: var(--text-secondary); + display: block; + margin-bottom: var(--sp-1); +} + +.deck-import-name-row input { + width: 100%; + background: var(--surface-1); + border: 1px solid var(--border); + color: var(--text); + padding: var(--sp-2) var(--sp-3); + border-radius: var(--radius-sm); + font-size: 14px; + outline: none; +} + +.deck-import-name-row input:focus { border-color: var(--accent); } + +/* Import progress (moved from inline styles) */ +.deck-import-progress-wrap { + margin-top: var(--sp-3); + padding: var(--sp-3); + background: var(--surface-0); + border-radius: var(--radius-sm); + border: 1px solid var(--border-subtle); +} + +.deck-import-progress-label { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: var(--sp-2); +} + +.deck-import-progress-track { + background: var(--surface-3); + border-radius: var(--radius-sm); + height: 6px; + overflow: hidden; +} + +.deck-import-progress-fill { + background: var(--accent); + height: 100%; + width: 0%; + border-radius: var(--radius-sm); + transition: width 0.3s var(--ease); +} + +/* Import file info (moved from inline style) */ +.deck-import-file-info { + margin-top: var(--sp-2); + font-size: 13px; + color: var(--text-secondary); +} + +.deck-btn-icon { + font-size: 11px; +} + +.deck-btn-full { + width: 100%; + justify-content: center; +} + +.deck-btn-sm { + padding: 4px var(--sp-3); + font-size: 12px; +} + +.deck-btn-xs { + padding: 3px var(--sp-2); + font-size: 11px; + border-radius: 5px; +} + +/* ── 4. Main 3-Column Layout ────────────────────────────── */ + +.deck-main { + display: flex; + flex-direction: row; + gap: 0; + flex: 1; + min-height: 0; + align-items: flex-start; +} + +/* LEFT COLUMN */ +.deck-col-left { + width: 300px; + flex-shrink: 0; + position: sticky; + top: calc(var(--nav-height) + 60px); + height: calc(100vh - var(--nav-height) - 60px); + overflow-y: auto; + overflow-x: hidden; + border-right: 1px solid var(--border); + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.deck-col-left::-webkit-scrollbar { width: 6px; } +.deck-col-left::-webkit-scrollbar-track { background: transparent; } +.deck-col-left::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +.deck-col-left::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } + +/* CENTER COLUMN */ +.deck-col-center { + flex: 1; + min-width: 0; + min-height: calc(100vh - var(--nav-height) - 60px); + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.deck-col-center::-webkit-scrollbar { width: 6px; } +.deck-col-center::-webkit-scrollbar-track { background: transparent; } +.deck-col-center::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +/* RIGHT COLUMN */ +.deck-col-right { + width: 360px; + flex-shrink: 0; + position: sticky; + top: calc(var(--nav-height) + 60px); + height: calc(100vh - var(--nav-height) - 60px); + overflow-y: auto; + overflow-x: hidden; + border-left: 1px solid var(--border); + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.deck-col-right::-webkit-scrollbar { width: 6px; } +.deck-col-right::-webkit-scrollbar-track { background: transparent; } +.deck-col-right::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +.deck-col-right::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } + +/* ── 5. Panels ──────────────────────────────────────────── */ + +.deck-panel { + background: var(--bg); +} + +.deck-panel + .deck-panel { + border-top: 1px solid var(--border); +} + +.deck-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-3) var(--sp-4); + background: var(--surface-1); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} + +.deck-panel-header-collapsible { + cursor: pointer; + user-select: none; + transition: background var(--duration) var(--ease); +} + +.deck-panel-header-collapsible:hover { + background: var(--accent-dim); +} + +.deck-panel-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text); +} + +.deck-collapse-arrow { + font-size: 10px; + color: var(--text-secondary); + transition: transform 0.2s var(--ease); +} + +.deck-panel-body { + padding: var(--sp-3) var(--sp-4); + overflow: hidden; + transition: max-height 0.25s ease, padding 0.25s ease; +} + +.deck-panel-body.collapsed { + max-height: 0 !important; + padding-top: 0; + padding-bottom: 0; +} + +.deck-collapse-arrow.collapsed { + transform: rotate(-90deg); +} + +/* ── 6. Left Column: Search Panel ───────────────────────── */ + +.deck-panel-search { + padding: 0; + display: flex; + flex-direction: column; + height: 100%; +} + +.deck-panel-search .deck-panel-header { + position: sticky; + top: 0; + z-index: 10; + flex-shrink: 0; +} + +.deck-search-count { + font-size: 11px; + color: var(--text-secondary); +} + +.deck-search-bar-wrap { + position: relative; + padding: var(--sp-3) var(--sp-3) var(--sp-2); + flex-shrink: 0; +} + +.deck-search-bar { + width: 100%; + background: var(--surface-1); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 7px 30px 7px var(--sp-3); + font-size: 13px; + font-family: inherit; + outline: none; + transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease); +} + +.deck-search-bar::placeholder { + color: var(--text-secondary); +} + +.deck-search-bar:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.deck-search-icon { + position: absolute; + right: 20px; + top: 50%; + transform: translateY(-50%); + font-size: 13px; + opacity: 0.4; + pointer-events: none; +} + +.deck-filter-row { + display: flex; + gap: var(--sp-2); + padding: 0 var(--sp-3) var(--sp-2); + flex-shrink: 0; +} + +.deck-filter-select { + flex: 1; + background: var(--surface-1); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 5px var(--sp-2); + font-size: 12px; + font-family: inherit; + outline: none; + cursor: pointer; + min-width: 0; + transition: border-color var(--duration) var(--ease); +} + +.deck-filter-select:hover, +.deck-filter-select:focus { + border-color: var(--accent); +} + +.deck-cmc-filter { + display: flex; + align-items: center; + gap: var(--sp-1); + flex-shrink: 0; +} + +.deck-cmc-input { + width: 38px; + background: var(--surface-1); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 5px var(--sp-1); + font-size: 12px; + font-family: inherit; + text-align: center; + outline: none; + transition: border-color var(--duration) var(--ease); + -moz-appearance: textfield; +} + +.deck-cmc-input::-webkit-outer-spin-button, +.deck-cmc-input::-webkit-inner-spin-button { + -webkit-appearance: none; +} + +.deck-cmc-input:focus { + border-color: var(--accent); +} + +.deck-cmc-sep { + color: var(--text-secondary); + font-size: 11px; +} + +/* Color filter row — MTG domain-specific colors */ +.deck-color-filter-row { + display: flex; + gap: 5px; + padding: 0 var(--sp-3) var(--sp-3); + flex-shrink: 0; +} + +.deck-color-pip { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 800; + line-height: 1; + transition: border-color var(--duration) var(--ease), transform var(--duration) var(--ease), box-shadow var(--duration) var(--ease); + user-select: none; + background: var(--surface-1); + color: var(--text-secondary); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + flex-shrink: 0; +} + +.deck-color-pip:hover { + border-color: rgba(255, 255, 255, 0.5); + transform: scale(1.05); +} + +.deck-color-pip.active { + border-color: #fff; + transform: scale(1.1); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5); +} + +.deck-color-W { background: #F9FAF4; color: #333; } +.deck-color-W.active { background: #F9FAF4; } +.deck-color-U { background: #0E68AB; color: #fff; } +.deck-color-U.active { background: #1a85d4; } +.deck-color-B { background: #2a1f1a; color: #c9a060; } +.deck-color-B.active { background: #3a2a1a; } +.deck-color-R { background: #D3202A; color: #fff; } +.deck-color-R.active { background: #e83040; } +.deck-color-G { background: #00733E; color: #fff; } +.deck-color-G.active { background: #009950; } +.deck-color-C { background: #7a7a7a; color: #fff; } +.deck-color-C.active { background: #9a9a9a; } + +/* Search results list */ +.deck-search-results { + flex: 1; + overflow-y: auto; + padding: 2px 0; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.deck-search-results::-webkit-scrollbar { width: 4px; } +.deck-search-results::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +.deck-search-footer { + padding: var(--sp-2) var(--sp-3); + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +/* ── 7. Search Result Row ───────────────────────────────── */ + +.deck-search-row { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 5px var(--sp-3); + border-bottom: 1px solid var(--border-subtle); + transition: background 0.1s; + min-height: 32px; +} + +.deck-search-row:hover { + background: var(--accent-dim); +} + +.deck-search-row.in-deck { + opacity: 0.55; +} + +.deck-search-row.in-deck:hover { + opacity: 0.75; + background: var(--success-dim); +} + +.deck-search-row-name { + flex: 1; + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + min-width: 0; +} + +.deck-search-row-name:hover { + color: var(--accent); +} + +.deck-search-row-mana { + display: flex; + gap: 2px; + align-items: center; + flex-shrink: 0; +} + +.deck-search-row-owned { + font-size: 10px; + color: var(--text-secondary); + flex-shrink: 0; + white-space: nowrap; + min-width: 22px; + text-align: right; +} + +.deck-search-row-owned.has-owned { + color: var(--success); + font-weight: 600; +} + +.deck-search-row-add { + background: none; + border: 1px solid var(--border); + color: var(--text-secondary); + border-radius: 4px; + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + cursor: pointer; + flex-shrink: 0; + transition: all var(--duration) var(--ease); + padding: 0; + line-height: 1; +} + +.deck-search-row-add:hover { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--accent); +} + +.deck-search-row.in-deck .deck-search-row-add { + border-color: rgba(74, 222, 128, 0.4); + color: var(--success); +} + +.deck-search-row.in-deck .deck-search-row-add:hover { + background: var(--success-dim); + border-color: var(--success); +} + +/* Type badges */ +.deck-type-badge { + display: inline-block; + padding: 1px 5px; + border-radius: 3px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; + flex-shrink: 0; + line-height: 1.4; +} + +.deck-type-Land { background: var(--warning-dim); color: var(--warning); border: 1px solid rgba(251, 191, 36, 0.3); } +.deck-type-Creature { background: var(--success-dim); color: var(--success); border: 1px solid rgba(74, 222, 128, 0.25); } +.deck-type-Instant { background: var(--accent-dim); color: var(--accent); border: 1px solid rgba(91, 158, 240, 0.25); } +.deck-type-Sorcery { background: var(--purple-dim); color: var(--purple); border: 1px solid rgba(167, 139, 250, 0.25); } +.deck-type-Artifact { background: rgba(139, 148, 158, 0.15); color: var(--text-secondary); border: 1px solid rgba(139, 148, 158, 0.25); } +.deck-type-Enchantment { background: var(--purple-dim); color: var(--purple); border: 1px solid rgba(167, 139, 250, 0.2); } +.deck-type-Planeswalker { background: var(--warning-dim); color: var(--gold); border: 1px solid rgba(252, 211, 77, 0.25); } +.deck-type-Other { background: rgba(139, 148, 158, 0.1); color: var(--text-secondary); border: 1px solid rgba(139, 148, 158, 0.2); } + +/* ── 8. Mana Pip Display ────────────────────────────────── */ + +.deck-mana-cost { + display: inline-flex; + gap: 2px; + align-items: center; + flex-wrap: nowrap; +} + +/* MTG mana pip colors — domain-specific */ +.deck-mana-pip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + border-radius: 50%; + font-size: 8px; + font-weight: 800; + line-height: 1; + border: 1px solid rgba(0, 0, 0, 0.3); + flex-shrink: 0; +} + +.deck-pip-W { background: #F9FAF4; color: #333; } +.deck-pip-U { background: #0E68AB; color: #fff; } +.deck-pip-B { background: #2a1f1a; color: #c9a060; border-color: #5a3a1a; } +.deck-pip-R { background: #D3202A; color: #fff; } +.deck-pip-G { background: #00733E; color: #fff; } +.deck-pip-C { background: #9a9a9a; color: #fff; } +.deck-pip-X { background: #4a4a4a; color: #fff; } +.deck-pip-N { background: var(--surface-1); color: var(--text); border-color: var(--border); } + +/* ── 9. Center Column: Card Groups ──────────────────────── */ + +.deck-panel-cards { + padding: 0; + display: flex; + flex-direction: column; + min-height: 100%; +} + +.deck-card-list-header { + position: sticky; + top: 0; + z-index: 10; +} + +.deck-card-list-meta { + font-size: 11px; + color: var(--text-secondary); +} + +.deck-card-list-actions { + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.deck-card-groups { + flex: 1; + padding: var(--sp-2) 0; +} + +/* Section / group header */ +.deck-section { + margin-bottom: 2px; +} + +.deck-section-commander .deck-section-header { + background: var(--accent-dim); + border-left: 3px solid var(--accent); +} + +.deck-section-commander .deck-section-name { + color: var(--accent); +} + +.deck-section-header { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-4); + cursor: pointer; + background: var(--surface-1); + border-bottom: 1px solid var(--border); + border-top: 1px solid var(--border); + position: sticky; + top: 40px; + z-index: 9; + user-select: none; + transition: background var(--duration) var(--ease); +} + +.deck-section-header:hover { + background: var(--accent-dim); +} + +.deck-section-toggle { + font-size: 9px; + color: var(--text-secondary); + transition: transform 0.2s var(--ease); + flex-shrink: 0; +} + +.deck-section-toggle.collapsed { + transform: rotate(-90deg); +} + +.deck-section-name { + font-size: 12px; + font-weight: 600; + color: var(--text); + flex: 1; +} + +.deck-section-count { + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; +} + +.deck-section-progress { + flex: 0 0 60px; + height: 4px; + border-radius: 2px; + background: var(--bg); + overflow: hidden; + flex-shrink: 0; +} + +.deck-section-progress-fill { + height: 100%; + border-radius: 2px; + transition: width 0.3s ease; +} + +.deck-section-progress-fill.status-ok { background: var(--success); } +.deck-section-progress-fill.status-warn { background: var(--warning); } +.deck-section-progress-fill.status-low { background: var(--error); } +.deck-section-progress-fill.status-over { background: var(--purple); } +.deck-section-progress-fill.status-neutral { background: var(--text-secondary); } + +/* Section body - card rows */ +.deck-section-body { + overflow: hidden; + transition: max-height 0.2s ease; +} + +.deck-section-body.collapsed { + max-height: 0 !important; +} + +/* Card row in deck */ +.deck-card-row { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-1) var(--sp-4); + border-bottom: 1px solid var(--border-subtle); + transition: background 0.1s; + min-height: 28px; +} + +.deck-card-row:hover { + background: var(--accent-dim); +} + +.deck-card-row.is-commander { + background: rgba(252, 211, 77, 0.04); +} + +.deck-card-row.is-commander .deck-card-row-name { + color: var(--gold); +} + +.deck-card-commander-badge { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--accent); + border: 1px solid rgba(91, 158, 240, 0.35); + border-radius: 3px; + padding: 1px 6px; + flex-shrink: 0; + margin-left: auto; +} + +.deck-card-row-name { + flex: 1; + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + min-width: 0; + transition: color 0.1s; +} + +.deck-card-row-name:hover { + color: var(--accent); +} + +.deck-card-row-mana { + display: flex; + gap: 2px; + align-items: center; + flex-shrink: 0; +} + +.deck-card-role-chip { + padding: 1px 5px; + border-radius: 3px; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + flex-shrink: 0; + background: rgba(139, 148, 158, 0.12); + color: var(--text-secondary); + border: 1px solid rgba(139, 148, 158, 0.2); + cursor: pointer; + transition: all var(--duration) var(--ease); +} + +.deck-card-role-chip:hover { + border-color: var(--accent); + color: var(--accent); +} + +/* Quantity controls */ +.deck-qty-controls { + display: flex; + align-items: center; + gap: var(--sp-1); + flex-shrink: 0; +} + +.deck-qty-btn { + background: none; + border: 1px solid var(--border); + color: var(--text-secondary); + border-radius: 3px; + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + cursor: pointer; + padding: 0; + line-height: 1; + transition: all 0.12s; + flex-shrink: 0; +} + +.deck-qty-btn:hover { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-dim); +} + +.deck-qty-num { + font-size: 12px; + color: var(--text); + min-width: 14px; + text-align: center; + font-weight: 600; +} + +.deck-card-remove-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; + padding: 0 2px; + border-radius: 3px; + line-height: 1; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.12s, color 0.12s; +} + +.deck-card-row:hover .deck-card-remove-btn { + opacity: 1; +} + +.deck-card-remove-btn:hover { + color: var(--error); +} + +/* Owned indicator on card rows */ +.deck-card-owned-dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + background: var(--success); + opacity: 0.8; +} + +.deck-card-owned-dot.unowned { + background: var(--text-secondary); + opacity: 0.4; +} + +/* Quick add row */ +.deck-quickadd-row { + display: flex; + gap: var(--sp-2); + align-items: center; + padding: var(--sp-3) var(--sp-4); + border-top: 1px solid var(--border); + background: var(--surface-1); + flex-shrink: 0; +} + +.deck-quickadd-wrap { + flex: 1; + position: relative; + min-width: 0; +} + +.deck-quickadd-input { + width: 100%; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 6px var(--sp-3); + font-size: 13px; + font-family: inherit; + outline: none; + transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease); +} + +.deck-quickadd-input::placeholder { + color: var(--text-secondary); +} + +.deck-quickadd-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.deck-quickadd-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + z-index: 50; + max-height: 200px; + overflow-y: auto; + box-shadow: var(--shadow-lg); +} + +.deck-quickadd-item { + padding: 7px var(--sp-3); + font-size: 12px; + cursor: pointer; + color: var(--text); + display: flex; + align-items: center; + gap: var(--sp-2); + border-bottom: 1px solid var(--border-subtle); + transition: background 0.1s; +} + +.deck-quickadd-item:hover, +.deck-quickadd-item.active { + background: var(--accent-dim); + color: var(--accent); +} + +.deck-quickadd-item:last-child { + border-bottom: none; +} + +.deck-quickadd-item-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deck-quickadd-item-type { + font-size: 10px; + color: var(--text-secondary); + flex-shrink: 0; +} + +/* Stats footer */ +.deck-stats-footer { + display: flex; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-4); + border-top: 1px solid var(--border); + background: var(--surface-1); + flex-shrink: 0; +} + +.deck-stat-pill { + display: flex; + align-items: baseline; + gap: var(--sp-1); + padding: var(--sp-1) var(--sp-3); + background: var(--bg); + border: 1px solid var(--border); + border-radius: 12px; +} + +.deck-stat-pill-num { + font-size: 14px; + font-weight: 700; + color: var(--text); +} + +.deck-stat-owned { + color: var(--success); +} + +.deck-stat-pill-label { + font-size: 10px; + text-transform: uppercase; + color: var(--text-secondary); + letter-spacing: 0.4px; +} + +/* ── 10. Right Column: Ratio Dashboard ──────────────────── */ + +.deck-ratio-bars { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.deck-ratio-placeholder { + font-size: 12px; + color: var(--text-secondary); + text-align: center; + padding: var(--sp-3) 0; + font-style: italic; +} + +.deck-ratio-bar-row { + display: flex; + align-items: center; + gap: var(--sp-2); + cursor: pointer; + border-radius: var(--radius-md); + padding: var(--sp-1) var(--sp-2); + transition: background var(--duration) var(--ease); +} + +.deck-ratio-bar-row:hover { + background: var(--accent-dim); +} + +.deck-ratio-bar-row.active-filter { + background: var(--accent-dim); + outline: 1px solid var(--accent-glow); +} + +.deck-ratio-bar-label { + font-size: 11px; + color: var(--text-secondary); + width: 80px; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.deck-ratio-bar-track { + flex: 1; + height: 8px; + background: var(--bg); + border-radius: 4px; + overflow: visible; + position: relative; +} + +.deck-ratio-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.35s ease; + position: relative; +} + +.deck-ratio-bar-fill.status-ok { background: var(--success); } +.deck-ratio-bar-fill.status-warn { background: var(--warning); } +.deck-ratio-bar-fill.status-low { background: var(--error); } +.deck-ratio-bar-fill.status-over { background: var(--purple); } +.deck-ratio-bar-fill.status-neutral { background: var(--text-secondary); } + +/* Target range indicator lines */ +.deck-ratio-bar-target-min, +.deck-ratio-bar-target-max { + position: absolute; + top: -2px; + bottom: -2px; + width: 2px; + border-radius: 1px; + background: rgba(255, 255, 255, 0.25); + z-index: 1; +} + +.deck-ratio-bar-nums { + font-size: 11px; + color: var(--text-secondary); + width: 50px; + flex-shrink: 0; + text-align: right; + white-space: nowrap; +} + +.deck-ratio-bar-nums .deck-ratio-current { + font-weight: 700; + color: var(--text); +} + +/* ── 11. Fix My Ratios Section ──────────────────────────── */ + +.deck-shortfalls { + display: flex; + flex-direction: column; + gap: var(--sp-1); + margin-bottom: var(--sp-3); +} + +.deck-shortfall-row { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 5px var(--sp-2); + border-radius: var(--radius-md); + background: var(--surface-1); + font-size: 12px; +} + +.deck-shortfall-icon { + font-size: 13px; + flex-shrink: 0; +} + +.deck-shortfall-text { + flex: 1; + color: var(--text); +} + +.deck-shortfall-delta { + font-size: 11px; + font-weight: 700; + padding: 1px 6px; + border-radius: 3px; + flex-shrink: 0; +} + +.deck-shortfall-delta.needs-more { + color: var(--warning); + background: var(--warning-dim); +} + +.deck-shortfall-delta.over-count { + color: var(--error); + background: var(--error-dim); +} + +.deck-shortfall-delta.on-target { + color: var(--success); + background: var(--success-dim); +} + +.deck-fix-suggestions { + margin-top: var(--sp-2); +} + +.deck-fix-group { + margin-bottom: var(--sp-3); +} + +.deck-fix-group-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--text-secondary); + margin-bottom: var(--sp-1); + padding: 0 2px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.deck-fix-card { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 5px var(--sp-2); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + margin-bottom: 3px; + transition: border-color var(--duration) var(--ease); +} + +.deck-fix-card:hover { + border-color: var(--accent); +} + +.deck-fix-card-name { + flex: 1; + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + min-width: 0; +} + +.deck-fix-card-name:hover { + color: var(--accent); +} + +.deck-fix-card-owned { + font-size: 10px; + color: var(--success); + font-weight: 600; + flex-shrink: 0; +} + +/* ── 12. EDHREC Recommendations ─────────────────────────── */ + +.deck-edhrec-controls { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: var(--sp-3); + border-bottom: 1px solid var(--border); + margin-bottom: var(--sp-3); +} + +.deck-edhrec-list { + display: flex; + flex-direction: column; + gap: 3px; +} + +.deck-edhrec-bulk { + margin-top: var(--sp-3); + padding-top: var(--sp-3); + border-top: 1px solid var(--border); +} + +.deck-edhrec-card { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 5px var(--sp-2); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + transition: border-color var(--duration) var(--ease), background var(--duration) var(--ease); +} + +.deck-edhrec-card:hover { + border-color: var(--accent-glow); + background: var(--accent-dim); +} + +.deck-edhrec-card.in-deck { + opacity: 0.5; + border-color: rgba(74, 222, 128, 0.3); +} + +.deck-edhrec-card-main { + flex: 1; + min-width: 0; +} + +.deck-edhrec-card-name { + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + line-height: 1.3; +} + +.deck-edhrec-card-name:hover { + color: var(--accent); +} + +.deck-edhrec-card-meta { + display: flex; + align-items: center; + gap: 5px; + margin-top: 2px; + flex-wrap: wrap; +} + +.deck-edhrec-pct { + font-size: 10px; + color: var(--text-secondary); +} + +.deck-edhrec-pct strong { + color: var(--accent); +} + +.deck-edhrec-synergy { + font-size: 10px; + color: var(--text-secondary); +} + +.deck-edhrec-synergy.positive { + color: var(--success); +} + +.deck-owned-badge { + font-size: 9px; + font-weight: 700; + padding: 1px 5px; + border-radius: 3px; + background: var(--success-dim); + color: var(--success); + border: 1px solid rgba(74, 222, 128, 0.3); + flex-shrink: 0; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +/* ── 13. Toggle Switch ───────────────────────────────────── */ + +.deck-toggle-label { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + cursor: pointer; + user-select: none; + font-size: 12px; + color: var(--text-secondary); +} + +.deck-toggle-check { + display: none; +} + +.deck-toggle-track { + width: 32px; + height: 18px; + background: var(--border); + border-radius: 9px; + position: relative; + transition: background 0.2s; + flex-shrink: 0; +} + +.deck-toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + background: var(--text-secondary); + border-radius: 50%; + transition: transform 0.2s, background 0.2s; +} + +.deck-toggle-check:checked + .deck-toggle-track { + background: var(--accent-glow); +} + +.deck-toggle-check:checked + .deck-toggle-track .deck-toggle-thumb { + transform: translateX(14px); + background: var(--accent); +} + +/* ── 14. Card Preview Tooltip ───────────────────────────── */ + +.deck-card-preview { + position: fixed; + z-index: 60; + pointer-events: none; + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.8); + width: 180px; + transition: opacity 0.1s; +} + +.deck-card-preview-img { + width: 100%; + display: block; + border-radius: var(--radius-md); +} + +/* ── 15. Toast Notifications ────────────────────────────── */ + +.deck-toast-container { + position: fixed; + bottom: var(--sp-5); + right: var(--sp-5); + z-index: 500; + display: flex; + flex-direction: column; + gap: var(--sp-2); + pointer-events: none; +} + +.deck-toast { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--sp-3) var(--sp-4); + font-size: 13px; + color: var(--text); + min-width: 240px; + max-width: 360px; + box-shadow: var(--shadow-lg); + animation: deck-toast-in 0.2s ease; + display: flex; + align-items: center; + gap: var(--sp-2); + pointer-events: auto; + border-left: 3px solid var(--border); +} + +.deck-toast.toast-success { border-left-color: var(--success); } +.deck-toast.toast-error { border-left-color: var(--error); } +.deck-toast.toast-warning { border-left-color: var(--warning); } +.deck-toast.toast-info { border-left-color: var(--accent); } + +.deck-toast-icon { + font-size: 15px; + flex-shrink: 0; +} + +.deck-toast-text { + flex: 1; + line-height: 1.4; +} + +.deck-toast.removing { + animation: deck-toast-out 0.2s ease forwards; +} + +@keyframes deck-toast-in { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes deck-toast-out { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(20px); } +} + +/* ── 16. Modal / Dialog ──────────────────────────────────── */ + +.deck-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + animation: deck-modal-bg-in 0.15s ease; +} + +@keyframes deck-modal-bg-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.deck-modal { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + width: 90%; + max-width: 460px; + max-height: 85vh; + overflow-y: auto; + animation: deck-modal-in 0.2s ease; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.8); +} + +.deck-modal-wide { + max-width: 600px; +} + +@keyframes deck-modal-in { + from { opacity: 0; transform: translateY(-12px) scale(0.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.deck-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-4) var(--sp-5) var(--sp-3); + border-bottom: 1px solid var(--border); +} + +.deck-modal-title { + font-size: 15px; + font-weight: 600; + color: #fff; + margin: 0; +} + +.deck-modal-close-btn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 16px; + cursor: pointer; + padding: 2px 6px; + border-radius: var(--radius-sm); + transition: color var(--duration) var(--ease), background var(--duration) var(--ease); + line-height: 1; +} + +.deck-modal-close-btn:hover { + color: #fff; + background: rgba(255, 255, 255, 0.1); +} + +.deck-modal-body { + padding: var(--sp-4) var(--sp-5); +} + +.deck-modal-footer { + display: flex; + justify-content: flex-end; + gap: var(--sp-2); + padding: var(--sp-3) var(--sp-5) var(--sp-4); + border-top: 1px solid var(--border); +} + +.deck-confirm-message { + font-size: 14px; + color: var(--text); + margin-bottom: var(--sp-3); + line-height: 1.5; +} + +.deck-confirm-changes { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--sp-3) var(--sp-4); + font-size: 12px; + display: flex; + flex-direction: column; + gap: var(--sp-1); +} + +.deck-confirm-change-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + padding: 2px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.deck-confirm-change-row:last-child { + border-bottom: none; + font-weight: 600; + margin-top: var(--sp-1); +} + +.deck-confirm-change-label { + color: var(--text-secondary); +} + +.deck-confirm-change-val { + color: var(--text); +} + +.deck-confirm-change-val.positive { color: var(--success); } +.deck-confirm-change-val.negative { color: var(--error); } + +/* ── 17. Form fields (New Deck Modal) ───────────────────── */ + +.deck-form-field { + margin-bottom: var(--sp-4); +} + +.deck-form-label { + display: block; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); + font-weight: 600; + margin-bottom: var(--sp-2); +} + +.deck-form-input { + width: 100%; + background: var(--surface-1); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 9px var(--sp-4); + font-size: 14px; + font-family: inherit; + outline: none; + transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease); +} + +.deck-form-input::placeholder { + color: var(--text-secondary); +} + +.deck-form-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.deck-commander-search-wrap { + position: relative; +} + +.deck-commander-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + z-index: 50; + max-height: 200px; + overflow-y: auto; + box-shadow: var(--shadow-lg); +} + +.deck-commander-option { + padding: var(--sp-2) var(--sp-4); + font-size: 13px; + cursor: pointer; + color: var(--text); + display: flex; + align-items: center; + gap: var(--sp-2); + border-bottom: 1px solid var(--border-subtle); + transition: background 0.1s; +} + +.deck-commander-option:hover, +.deck-commander-option.active { + background: var(--accent-dim); + color: var(--accent); +} + +.deck-commander-option:last-child { + border-bottom: none; +} + +.deck-commander-option-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deck-commander-option-color { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +.deck-new-commander-selected { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + margin-top: var(--sp-2); + background: var(--accent-dim); + border: 1px solid var(--accent-glow); + border-radius: var(--radius-md); +} + +.deck-selected-commander-name { + flex: 1; + font-size: 13px; + color: var(--accent); + font-weight: 500; +} + +.deck-clear-commander-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + padding: 0; + line-height: 1; + transition: color var(--duration) var(--ease); +} + +.deck-clear-commander-btn:hover { + color: var(--error); +} + +/* ── 18. Empty States ────────────────────────────────────── */ + +.deck-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: var(--sp-8) var(--sp-4); + text-align: center; +} + +.deck-empty-state-lg { + padding: var(--sp-16) var(--sp-6); +} + +.deck-empty-icon { + font-size: 32px; + opacity: 0.3; + line-height: 1; +} + +.deck-empty-state-lg .deck-empty-icon { + font-size: 48px; +} + +.deck-empty-text { + font-size: 13px; + color: var(--text-secondary); + max-width: 220px; + line-height: 1.5; +} + +/* ── 19. Loading / Skeleton ──────────────────────────────── */ + +.deck-skeleton { + background: linear-gradient( + 90deg, + var(--surface-1) 25%, + var(--surface-3) 50%, + var(--surface-1) 75% + ); + background-size: 200% 100%; + animation: deck-shimmer 1.4s infinite; + border-radius: 4px; +} + +@keyframes deck-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.deck-skeleton-row { + height: 28px; + margin: var(--sp-1) var(--sp-4); + border-radius: 4px; +} + +.deck-loading-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: deck-spin 0.6s linear infinite; + flex-shrink: 0; +} + +@keyframes deck-spin { + to { transform: rotate(360deg); } +} + +.deck-loading-inline { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: var(--sp-5); + font-size: 12px; + color: var(--text-secondary); +} + +/* ── 20. Scrollbar thin style for inner lists ───────────── */ + +.deck-edhrec-list, +.deck-fix-suggestions { + max-height: 260px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.deck-edhrec-list::-webkit-scrollbar, +.deck-fix-suggestions::-webkit-scrollbar { + width: 4px; +} + +.deck-edhrec-list::-webkit-scrollbar-thumb, +.deck-fix-suggestions::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + +/* ── 21. Mana curve sparkline ───────────────────────────── */ + +.deck-mana-curve { + display: flex; + align-items: flex-end; + gap: 3px; + height: 32px; + padding: 0 2px; +} + +.deck-curve-bar { + flex: 1; + background: var(--accent-glow); + border-radius: 2px 2px 0 0; + min-height: 2px; + transition: height 0.3s ease; + position: relative; +} + +.deck-curve-bar:hover { + background: var(--accent); +} + +.deck-curve-bar-label { + position: absolute; + bottom: -14px; + left: 50%; + transform: translateX(-50%); + font-size: 8px; + color: var(--text-secondary); + white-space: nowrap; +} + +/* ── 22. Import Status ──────────────────────────────────── */ + +.deck-import-status { + margin-top: var(--sp-3); + padding: var(--sp-3); + border-radius: var(--radius-sm); + font-size: 13px; +} + +/* ── 23. Responsive ──────────────────────────────────────── */ + +@media (max-width: 1200px) { + .deck-col-right { + width: 300px; + } +} + +@media (max-width: 1000px) { + .deck-main { + flex-direction: column; + } + + .deck-col-left, + .deck-col-center, + .deck-col-right { + width: 100%; + position: static; + height: auto; + border: none; + border-bottom: 1px solid var(--border); + } + + .deck-col-left { + max-height: 60vh; + overflow-y: auto; + } + + .deck-col-center { + min-height: unset; + } + + .deck-col-right { + height: auto; + } + + .deck-topbar { + flex-wrap: wrap; + gap: var(--sp-2); + padding: var(--sp-3); + } + + .deck-topbar-center { + order: 2; + flex-wrap: wrap; + gap: var(--sp-2); + } + + .deck-topbar-actions { + order: 3; + width: 100%; + } + + .deck-name-input { + width: 150px; + } +} + +@media (max-width: 768px) { + .deck-topbar-commander { + flex-wrap: wrap; + } + + .deck-nav-links { + display: none; + } + + .deck-color-chips { + flex-wrap: wrap; + } + + .deck-modal { + width: 95%; + max-width: none; + margin: var(--sp-3); + } + + .deck-card-preview { + display: none !important; + } + + .deck-col-left { + max-height: 45vh; + } +} + +/* ── 24. Utility ─────────────────────────────────────────── */ + +.deck-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; +} + +.deck-divider { + height: 1px; + background: var(--border); + margin: var(--sp-2) 0; +} + +.deck-text-muted { + color: var(--text-secondary); +} + +.deck-text-accent { + color: var(--accent); +} + +.deck-text-success { + color: var(--success); +} + +.deck-text-danger { + color: var(--error); +} + +.deck-text-warning { + color: var(--warning); +} + +/* ── 20. AI Panel Styles (Perplexity Research/Generate) ──── */ + +.deck-panel-ai .deck-panel-header { + background: linear-gradient(135deg, var(--surface-1) 70%, rgba(91, 158, 240, 0.1)); +} + +.deck-panel-ai .deck-panel-title { + color: var(--accent); +} + +.deck-ai-controls { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.deck-ai-row { + display: flex; + align-items: flex-end; + gap: var(--sp-3); +} + +.deck-form-field-sm { + margin-bottom: 0; +} + +.deck-form-field-half { + flex: 1; + min-width: 0; +} + +.deck-form-label-sm { + display: block; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--text-secondary); + font-weight: 600; + margin-bottom: 4px; +} + +.deck-form-input-sm { + width: 100%; + background: var(--surface-1); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 6px 10px; + font-size: 12px; + font-family: inherit; + outline: none; + transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease); +} + +.deck-form-input-sm::placeholder { + color: var(--text-secondary); +} + +.deck-form-input-sm:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.deck-toggle-label-sm { + font-size: 11px; + white-space: nowrap; + padding-bottom: 4px; +} + +.deck-btn-ai { + background: linear-gradient(135deg, rgba(91, 158, 240, 0.15), rgba(139, 92, 246, 0.15)); + color: var(--accent); + border-color: rgba(91, 158, 240, 0.35); + font-weight: 600; + margin-top: var(--sp-2); + position: relative; + overflow: hidden; +} + +.deck-btn-ai::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(91, 158, 240, 0.08), rgba(139, 92, 246, 0.12)); + opacity: 0; + transition: opacity 0.2s; +} + +.deck-btn-ai:hover:not(:disabled)::before { + opacity: 1; +} + +.deck-btn-ai:hover:not(:disabled) { + border-color: var(--accent); + box-shadow: 0 0 16px rgba(91, 158, 240, 0.2); +} + +.deck-btn-ai:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* AI Loading Spinner */ +.deck-ai-loading { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-4) 0; + color: var(--text-secondary); + font-size: 12px; +} + +.deck-ai-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: ai-spin 0.8s linear infinite; + flex-shrink: 0; +} + +@keyframes ai-spin { + to { transform: rotate(360deg); } +} + +/* AI Results */ +.deck-ai-results { + margin-top: var(--sp-3); +} + +.deck-ai-section { + margin-bottom: var(--sp-4); +} + +.deck-ai-section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--accent); + margin-bottom: var(--sp-2); + padding-bottom: 4px; + border-bottom: 1px solid var(--border); +} + +.deck-ai-rating { + display: flex; + align-items: center; + gap: var(--sp-3); + margin-bottom: var(--sp-3); + padding: var(--sp-3); + background: var(--surface-1); + border-radius: var(--radius-md); + border: 1px solid var(--border); +} + +.deck-ai-rating-num { + font-size: 28px; + font-weight: 700; + line-height: 1; +} + +.deck-ai-rating-num.low { color: var(--error); } +.deck-ai-rating-num.mid { color: var(--warning); } +.deck-ai-rating-num.high { color: var(--success); } + +.deck-ai-rating-label { + font-size: 12px; + color: var(--text-secondary); + flex: 1; + min-width: 0; +} + +.deck-ai-list { + list-style: none; + padding: 0; + margin: 0; +} + +.deck-ai-list li { + font-size: 12px; + color: var(--text); + padding: 4px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + line-height: 1.4; +} + +.deck-ai-list li:last-child { + border-bottom: none; +} + +.deck-ai-list li::before { + content: '\2022'; + color: var(--text-secondary); + margin-right: 6px; +} + +/* Cuts / Adds card rows */ +.deck-ai-card-row { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 5px var(--sp-2); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + margin-bottom: 3px; + transition: border-color var(--duration) var(--ease); +} + +.deck-ai-card-row:hover { + border-color: var(--accent-glow); +} + +.deck-ai-card-row.cut-row { + border-left: 3px solid var(--error); +} + +.deck-ai-card-row.add-row { + border-left: 3px solid var(--success); +} + +.deck-ai-card-name { + font-size: 12px; + color: var(--text); + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +.deck-ai-card-name:hover { + color: var(--accent); +} + +.deck-ai-card-role { + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.3px; + padding: 1px 5px; + border-radius: 3px; + background: var(--accent-dim); + color: var(--accent); + flex-shrink: 0; +} + +.deck-ai-card-price { + font-size: 10px; + color: var(--text-secondary); + flex-shrink: 0; +} + +.deck-ai-card-reason { + font-size: 10px; + color: var(--text-secondary); + margin-top: 2px; + line-height: 1.3; +} + +.deck-ai-card-owned-badge { + font-size: 8px; + font-weight: 700; + padding: 1px 4px; + border-radius: 3px; + background: var(--success-dim); + color: var(--success); + border: 1px solid rgba(74, 222, 128, 0.3); + flex-shrink: 0; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.deck-ai-card-add-btn { + background: transparent; + border: 1px solid var(--success); + color: var(--success); + border-radius: var(--radius-sm); + padding: 2px 6px; + font-size: 10px; + cursor: pointer; + flex-shrink: 0; + transition: background var(--duration) var(--ease); +} + +.deck-ai-card-add-btn:hover { + background: var(--success-dim); +} + +.deck-ai-notes { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + padding: var(--sp-2) 0; +} + +.deck-ai-total { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-2) var(--sp-3); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + margin-top: var(--sp-2); + font-size: 12px; + color: var(--text-secondary); +} + +.deck-ai-total strong { + color: var(--text); +} + +/* AI Generate — Commander search dropdown */ +.deck-ai-commander-wrap { + position: relative; +} + +.deck-ai-commander-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + z-index: 50; + max-height: 180px; + overflow-y: auto; + box-shadow: var(--shadow-lg); +} + +.deck-ai-commander-option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + cursor: pointer; + transition: background 0.12s; + font-size: 12px; +} + +.deck-ai-commander-option:hover, +.deck-ai-commander-option.active { + background: var(--accent-dim); +} + +.deck-ai-commander-option-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deck-ai-commander-selected { + display: flex; + align-items: center; + gap: var(--sp-2); + margin-top: 4px; + padding: 4px 8px; + background: var(--accent-dim); + border: 1px solid var(--accent-glow); + border-radius: var(--radius-sm); + font-size: 12px; + color: var(--accent); +} + +.deck-ai-commander-selected .deck-clear-commander-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 12px; + padding: 0 2px; +} + +.deck-ai-commander-selected .deck-clear-commander-btn:hover { + color: var(--error); +} + +/* AI Generate — Apply to Deck button */ +.deck-btn-apply-ai { + background: var(--success-dim); + color: var(--success); + border-color: rgba(74, 222, 128, 0.4); + font-weight: 600; + margin-top: var(--sp-3); +} + +.deck-btn-apply-ai:hover:not(:disabled) { + background: rgba(74, 222, 128, 0.25); + border-color: var(--success); + box-shadow: 0 0 12px var(--success-dim); +} + +/* AI error message */ +.deck-ai-error { + font-size: 12px; + color: var(--error); + padding: var(--sp-3) 0; + line-height: 1.4; +} + +/* Strategy text block */ +.deck-ai-strategy { + font-size: 12px; + color: var(--text); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--sp-3); + line-height: 1.5; + margin-bottom: var(--sp-3); +} + +/* Stats row for generated deck */ +.deck-ai-stats { + display: flex; + gap: var(--sp-3); + flex-wrap: wrap; + margin-bottom: var(--sp-3); +} + +.deck-ai-stat { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--sp-2) var(--sp-3); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + flex: 1; + min-width: 60px; +} + +.deck-ai-stat-value { + font-size: 18px; + font-weight: 700; + color: var(--text); +} + +.deck-ai-stat-label { + font-size: 9px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.4px; +} + +/* ═══════════════════════════════════════════════════════════ + AI Deep Analysis — Research Results Styles + ═══════════════════════════════════════════════════════════ */ + +/* ── Header Row: Rating + Bracket + Archetype inline ──── */ +.deck-ai-header-row { + display: flex; + align-items: center; + gap: var(--sp-3); + flex-wrap: wrap; + margin-bottom: var(--sp-3); + padding-bottom: var(--sp-3); + border-bottom: 1px solid var(--border); +} + +.deck-ai-rating-sublabel { + font-size: 12px; + font-weight: 400; + color: var(--text-secondary); + margin-left: 1px; +} + +/* ── Bracket Badge ──────────────────────────────────────── */ +.deck-ai-bracket-badge { + font-size: 10px; + font-weight: 700; + padding: 3px 10px; + border-radius: 20px; + text-transform: uppercase; + letter-spacing: 0.5px; + flex-shrink: 0; +} + +.deck-ai-bracket-badge.bracket-1 { + background: rgba(74, 222, 128, 0.15); + color: var(--success); + border: 1px solid rgba(74, 222, 128, 0.3); +} + +.deck-ai-bracket-badge.bracket-2 { + background: rgba(91, 158, 240, 0.15); + color: var(--accent); + border: 1px solid rgba(91, 158, 240, 0.3); +} + +.deck-ai-bracket-badge.bracket-3 { + background: rgba(251, 191, 36, 0.15); + color: var(--warning); + border: 1px solid rgba(251, 191, 36, 0.3); +} + +.deck-ai-bracket-badge.bracket-4 { + background: rgba(248, 113, 113, 0.15); + color: var(--error); + border: 1px solid rgba(248, 113, 113, 0.3); +} + +/* ── Archetype Badge ────────────────────────────────────── */ +.deck-ai-archetype-badge { + font-size: 10px; + font-weight: 600; + padding: 3px 10px; + border-radius: 20px; + background: var(--purple-dim); + color: var(--purple); + border: 1px solid rgba(167, 139, 250, 0.3); + text-transform: capitalize; + flex-shrink: 0; +} + +/* ── Rating Explanation ─────────────────────────────────── */ +.deck-ai-rating-explanation { + font-style: italic; + color: var(--text-secondary); + font-size: 11px; + padding: var(--sp-1) 0 var(--sp-2); + border-bottom: 1px solid var(--border-subtle); + margin-bottom: var(--sp-2); +} + +/* ── Bracket Detail block ──────────────────────────────── */ +.deck-ai-bracket-detail { + padding: var(--sp-2) var(--sp-3); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); +} + +/* ── Win Conditions ─────────────────────────────────────── */ +.deck-ai-wincon { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--sp-3); + margin-bottom: var(--sp-2); +} + +.deck-ai-wincon-header { + display: flex; + align-items: center; + gap: var(--sp-2); + margin-bottom: var(--sp-2); +} + +.deck-ai-wincon-name { + font-size: 13px; + font-weight: 700; + color: var(--text); +} + +.deck-ai-wincon-cards { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: var(--sp-1); +} + +/* ── Reliability Badge (high/mid/low) ──────────────────── */ +.deck-ai-reliability-badge { + font-size: 9px; + font-weight: 700; + padding: 2px 7px; + border-radius: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + flex-shrink: 0; +} + +.deck-ai-reliability-badge.high { + background: rgba(74, 222, 128, 0.15); + color: var(--success); + border: 1px solid rgba(74, 222, 128, 0.3); +} + +.deck-ai-reliability-badge.mid { + background: rgba(251, 191, 36, 0.15); + color: var(--warning); + border: 1px solid rgba(251, 191, 36, 0.3); +} + +.deck-ai-reliability-badge.low { + background: rgba(248, 113, 113, 0.15); + color: var(--error); + border: 1px solid rgba(248, 113, 113, 0.3); +} + +/* ── Card Chip (inline card references) ─────────────────── */ +.deck-ai-card-chip { + display: inline-block; + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + background: var(--accent-dim); + color: var(--accent); + border: 1px solid rgba(91, 158, 240, 0.2); + white-space: nowrap; +} + +.deck-ai-card-chip-warn { + background: var(--error-dim); + color: var(--error); + border-color: rgba(248, 113, 113, 0.25); +} + +/* ── Synergy Packages ───────────────────────────────────── */ +.deck-ai-synergy-pkg { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--sp-3); + margin-bottom: var(--sp-2); +} + +.deck-ai-synergy-header { + display: flex; + align-items: center; + gap: var(--sp-2); + margin-bottom: var(--sp-2); +} + +.deck-ai-synergy-name { + font-size: 13px; + font-weight: 700; + color: var(--text); +} + +/* ── Threat Assessment (game phases) ───────────────────── */ +.deck-ai-phases { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.deck-ai-phase { + display: flex; + gap: var(--sp-3); + padding: var(--sp-2) var(--sp-3); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + align-items: flex-start; +} + +.deck-ai-phase-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--accent); + flex-shrink: 0; + min-width: 80px; + padding-top: 1px; +} + +.deck-ai-phase-text { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; +} + +.deck-ai-phase-vuln .deck-ai-phase-label { + color: var(--error); +} + +.deck-ai-phase-vuln { + border-color: rgba(248, 113, 113, 0.2); + background: var(--error-dim); +} + +/* ── Strengths / Weaknesses side-by-side grid ──────────── */ +.deck-ai-sw-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--sp-3); +} + +.deck-ai-sw-col { + min-width: 0; +} + +.deck-ai-sw-title-good { + color: var(--success) !important; +} + +.deck-ai-sw-title-bad { + color: var(--error) !important; +} + +.deck-ai-list { + list-style: none; + padding: 0; + margin: 0; +} + +.deck-ai-list li { + font-size: 12px; + color: var(--text-secondary); + padding: 3px 0; + line-height: 1.4; + position: relative; + padding-left: 14px; +} + +.deck-ai-list li::before { + content: '\2022'; + position: absolute; + left: 2px; + color: var(--text-tertiary); +} + +/* ── Role Analysis (progress bar rows) ─────────────────── */ +.deck-ai-role-gaps { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.deck-ai-role-row { + display: grid; + grid-template-columns: 90px 1fr 50px; + align-items: center; + gap: var(--sp-2); + font-size: 12px; +} + +.deck-ai-role-name { + font-weight: 600; + color: var(--text); + text-transform: capitalize; + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.deck-ai-role-bar-wrap { + height: 8px; + border-radius: 4px; + background: var(--surface-2); + overflow: hidden; +} + +.deck-ai-role-bar { + height: 100%; + border-radius: 4px; + transition: width 0.4s ease; + min-width: 2px; +} + +.deck-ai-role-bar.good { + background: var(--success); +} + +.deck-ai-role-bar.warn { + background: var(--warning); +} + +.deck-ai-role-bar.bad { + background: var(--error); +} + +.deck-ai-role-nums { + font-size: 10px; + color: var(--text-secondary); + text-align: right; + font-variant-numeric: tabular-nums; +} + +.deck-ai-role-note { + grid-column: 1 / -1; + font-size: 10px; + color: var(--text-tertiary); + line-height: 1.3; + padding-left: 92px; + margin-top: -2px; +} + +/* ── Severity Badge (cuts) ──────────────────────────────── */ +.deck-ai-severity-badge { + font-size: 8px; + font-weight: 700; + padding: 2px 6px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.4px; + flex-shrink: 0; +} + +.deck-ai-severity-badge.sev-must { + background: rgba(248, 113, 113, 0.2); + color: var(--error); + border: 1px solid rgba(248, 113, 113, 0.3); +} + +.deck-ai-severity-badge.sev-should { + background: rgba(251, 191, 36, 0.2); + color: var(--warning); + border: 1px solid rgba(251, 191, 36, 0.3); +} + +.deck-ai-severity-badge.sev-consider { + background: rgba(91, 158, 240, 0.15); + color: var(--accent); + border: 1px solid rgba(91, 158, 240, 0.2); +} + +/* ── Priority Badge (adds) ──────────────────────────────── */ +.deck-ai-priority-badge { + font-size: 8px; + font-weight: 700; + padding: 2px 6px; + border-radius: 3px; + text-transform: uppercase; + letter-spacing: 0.4px; + flex-shrink: 0; +} + +.deck-ai-priority-badge.pri-critical { + background: rgba(248, 113, 113, 0.2); + color: var(--error); + border: 1px solid rgba(248, 113, 113, 0.3); +} + +.deck-ai-priority-badge.pri-high { + background: rgba(251, 191, 36, 0.2); + color: var(--warning); + border: 1px solid rgba(251, 191, 36, 0.3); +} + +.deck-ai-priority-badge.pri-medium { + background: rgba(91, 158, 240, 0.15); + color: var(--accent); + border: 1px solid rgba(91, 158, 240, 0.2); +} + +.deck-ai-priority-badge.pri-nice { + background: rgba(74, 222, 128, 0.12); + color: var(--success); + border: 1px solid rgba(74, 222, 128, 0.2); +} + +/* ── Card Detail line (reason / synergy) ────────────────── */ +.deck-ai-card-detail { + font-size: 10px; + color: var(--text-secondary); + line-height: 1.4; + padding: 2px 0 0 14px; + margin-top: 2px; +} + +/* ── Synergy Tag (inline in card detail) ────────────────── */ +.deck-ai-synergy-tag { + display: inline-block; + font-size: 9px; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + background: var(--purple-dim); + color: var(--purple); + border: 1px solid rgba(167, 139, 250, 0.2); + margin-left: 4px; + vertical-align: middle; +} + +/* ── Deep Analysis responsive tweak ─────────────────────── */ +@media (max-width: 600px) { + .deck-ai-sw-grid { + grid-template-columns: 1fr; + } + .deck-ai-role-row { + grid-template-columns: 70px 1fr 40px; + } + .deck-ai-role-note { + padding-left: 72px; + } + .deck-ai-phase { + flex-direction: column; + gap: var(--sp-1); + } + .deck-ai-phase-label { + min-width: auto; + } +} diff --git a/lan-client/deckbuilder.html b/lan-client/deckbuilder.html new file mode 100644 index 0000000..f9059fa --- /dev/null +++ b/lan-client/deckbuilder.html @@ -0,0 +1,495 @@ + + + + + + Commander AI Lab — Deck Builder + + + + + + + + + + +
+ + +
+
+
+ ⚔ + +
+
+ +
No commander selected
+
+
+
+ +
+
+ 0 + / + 100 + cards +
+ +
+ +
+ + +
+ +
+ + + + + +
+
+ + +
+ + + + + +
+
+
+ Deck Contents +
+ Select or create a deck +
+
+ + +
+
+
⚔
+
Create or select a deck to start building
+ +
+
+ + + + + + +
+
+ + + +
+ + + + + +
+ + + + + + + + + + +
+ + + + + + + diff --git a/lan-client/deckbuilder.js b/lan-client/deckbuilder.js new file mode 100644 index 0000000..ee1c157 --- /dev/null +++ b/lan-client/deckbuilder.js @@ -0,0 +1,2751 @@ +/* ═══════════════════════════════════════════════════════════ + Commander AI Lab — Deck Builder + Pure vanilla JS, no frameworks. + Communicates with backend via fetch() to relative /api/ URLs. + ═══════════════════════════════════════════════════════════ */ + +'use strict'; + +/* ── LAN Client API Base URL ──────────────────────────────── + When served from a different host than lab_api.py, set + window.LAB_API_BASE before this script loads. */ +const LAB_API_BASE = window.LAB_API_BASE || ''; + +/* ── Mana Cost Rendering ─────────────────────────────────── */ + +const MANA_COLORS = { + W: 'deck-pip-W', + U: 'deck-pip-U', + B: 'deck-pip-B', + R: 'deck-pip-R', + G: 'deck-pip-G', + C: 'deck-pip-C', + X: 'deck-pip-X', +}; + +/** + * Parse a mana cost string like "{2}{W}{U}" and return an array + * of { label, cls } objects. + */ +function parseMana(costStr) { + if (!costStr) return []; + const pips = []; + const regex = /\{([^}]+)\}/g; + let m; + while ((m = regex.exec(costStr)) !== null) { + const sym = m[1].toUpperCase(); + if (MANA_COLORS[sym]) { + pips.push({ label: sym, cls: MANA_COLORS[sym] }); + } else if (/^\d+$/.test(sym)) { + pips.push({ label: sym, cls: 'deck-pip-N' }); + } else if (sym === 'X') { + pips.push({ label: 'X', cls: 'deck-pip-X' }); + } else { + // Hybrid or phyrexian — pick first color + const first = sym[0]; + pips.push({ label: first, cls: MANA_COLORS[first] || 'deck-pip-N' }); + } + } + return pips; +} + +/** Build a mana cost element. */ +function buildManaCost(costStr) { + const wrap = el('span', 'deck-mana-cost'); + for (const pip of parseMana(costStr)) { + const span = el('span', `deck-mana-pip ${pip.cls}`); + span.textContent = pip.label; + wrap.appendChild(span); + } + return wrap; +} + +/* ── DOM Helpers ─────────────────────────────────────────── */ + +/** Quick element factory. */ +function el(tag, className, text) { + const e = document.createElement(tag); + if (className) e.className = className; + if (text !== undefined) e.textContent = text; + return e; +} + +function qs(sel, root) { return (root || document).querySelector(sel); } +function qsa(sel, root) { return Array.from((root || document).querySelectorAll(sel)); } + +/* ── Toast System ────────────────────────────────────────── */ + +const ToastManager = { + container: null, + init() { + this.container = qs('#deck-toast-container'); + }, + show(message, type = 'info', duration = 3000) { + const icons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' }; + const toast = el('div', `deck-toast toast-${type}`); + const icon = el('span', 'deck-toast-icon', icons[type] || icons.info); + const text = el('span', 'deck-toast-text', message); + toast.appendChild(icon); + toast.appendChild(text); + this.container.appendChild(toast); + + const remove = () => { + toast.classList.add('removing'); + setTimeout(() => toast.remove(), 220); + }; + setTimeout(remove, duration); + toast.addEventListener('click', remove); + } +}; + +/* ── Card Preview (hover tooltip) ────────────────────────── */ + +const CardPreview = { + el: null, + img: null, + visible: false, _hoverTimer: null, HOVER_DELAY: 225, + + init() { + this.el = qs('#deck-card-preview'); + this.img = qs('#deck-card-preview-img'); + }, + + show(scryfallId, mx, my) { + if (!scryfallId || !this.el) return; + this.img.src = `https://api.scryfall.com/cards/${scryfallId}?format=image&version=normal`; + this.img.onerror = () => this.hide(); + + const vw = window.innerWidth; + const vh = window.innerHeight; + const w = 180; + let x = mx + 16; + let y = my - 20; + if (x + w > vw - 10) x = mx - w - 16; + if (y + 252 > vh - 10) y = vh - 262; + if (y < 10) y = 10; + + this.el.style.left = `${x}px`; + this.el.style.top = `${y}px`; + this.el.style.display = 'block'; + this.visible = true; + }, + + hide() { + clearTimeout(this._hoverTimer); if (this.el) this.el.style.display = 'none'; + this.visible = false; + }, + + attach(nameEl, scryfallId) { + if (!scryfallId) return; + let lastE = null; nameEl.addEventListener('mouseenter', (e) => { lastE = e; clearTimeout(this._hoverTimer); this._hoverTimer = setTimeout(() => { if (lastE) this.show(scryfallId, lastE.clientX, lastE.clientY); }, this.HOVER_DELAY); }); + nameEl.addEventListener('mousemove', (e) => { lastE = e; if (this.visible) this.show(scryfallId, e.clientX, e.clientY); }); + nameEl.addEventListener('mouseleave', () => this.hide()); + } +}; + +/* ── Confirmation Modal ───────────────────────────────────── */ + +const ConfirmDialog = { + overlay: null, + titleEl: null, + messageEl: null, + changesEl: null, + okBtn: null, + cancelBtn: null, + closeBtn: null, + _resolve: null, + + init() { + this.overlay = qs('#deck-confirm-modal'); + this.titleEl = qs('#deck-confirm-title'); + this.messageEl = qs('#deck-confirm-message'); + this.changesEl = qs('#deck-confirm-changes'); + this.okBtn = qs('#deck-confirm-ok'); + this.cancelBtn = qs('#deck-confirm-cancel'); + this.closeBtn = qs('#deck-confirm-close'); + + const close = () => { this.overlay.style.display = 'none'; if (this._resolve) this._resolve(false); }; + this.cancelBtn.addEventListener('click', close); + this.closeBtn.addEventListener('click', close); + this.overlay.addEventListener('click', (e) => { if (e.target === this.overlay) close(); }); + this.okBtn.addEventListener('click', () => { + this.overlay.style.display = 'none'; + if (this._resolve) this._resolve(true); + }); + }, + + /** + * @param {object} opts - { title, message, changes: [{label, before, after}] } + * @returns {Promise} + */ + show({ title = 'Confirm Action', message = '', changes = [], okLabel = 'OK', cancelLabel = 'Cancel' }) { + this.titleEl.textContent = title; + this.messageEl.textContent = message; + this.changesEl.innerHTML = ''; + this.okBtn.textContent = okLabel; + this.cancelBtn.textContent = cancelLabel; + + if (changes.length > 0) { + for (const c of changes) { + const row = el('div', 'deck-confirm-change-row'); + const lbl = el('span', 'deck-confirm-change-label', c.label); + const diff = (c.after - c.before); + const cls = diff > 0 ? 'positive' : diff < 0 ? 'negative' : ''; + const val = el('span', `deck-confirm-change-val ${cls}`, `${c.before} → ${c.after}`); + row.appendChild(lbl); + row.appendChild(val); + this.changesEl.appendChild(row); + } + } else { + this.changesEl.style.display = 'none'; + } + if (changes.length > 0) { + this.changesEl.style.display = ''; + } + + this.overlay.style.display = 'flex'; + return new Promise(resolve => { this._resolve = resolve; }); + } +}; + +/* ── API Helpers ─────────────────────────────────────────── */ + +async function apiFetch(path, options = {}) { + const url = path.startsWith('/api') ? LAB_API_BASE + path : path; + const res = await fetch(url, { + headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, + ...options, + }); + if (!res.ok) { + let errMsg = `HTTP ${res.status}`; + try { const body = await res.json(); errMsg = body.detail || body.message || errMsg; } catch (_) {} + throw new Error(errMsg); + } + if (res.status === 204) return null; + return res.json(); +} + +function apiGet(path) { return apiFetch(path); } +function apiPost(path, data) { return apiFetch(path, { method: 'POST', body: JSON.stringify(data) }); } +function apiPut(path, data) { return apiFetch(path, { method: 'PUT', body: JSON.stringify(data) }); } +function apiPatch(path, data) { return apiFetch(path, { method: 'PATCH', body: JSON.stringify(data) }); } +function apiDelete(path) { return apiFetch(path, { method: 'DELETE' }); } + +/* ── Scryfall Autocomplete ────────────────────────────────── */ + +let _scryfallCache = {}; + +async function scryfallAutocomplete(query) { + if (!query || query.length < 2) return []; + if (_scryfallCache[query]) return _scryfallCache[query]; + try { + const res = await fetch(`https://api.scryfall.com/cards/autocomplete?q=${encodeURIComponent(query)}`); + if (!res.ok) return []; + const json = await res.json(); + _scryfallCache[query] = json.data || []; + return _scryfallCache[query]; + } catch (_) { + return []; + } +} + +async function scryfallFuzzySearch(query) { + try { + const res = await fetch(`https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(query)}`); + if (!res.ok) return null; + return res.json(); + } catch (_) { + return null; + } +} + +async function scryfallSearchCards(query) { + try { + const res = await fetch(`https://api.scryfall.com/cards/search?q=${encodeURIComponent(query)}&unique=cards&order=name`); + if (!res.ok) return []; + const json = await res.json(); + return json.data || []; + } catch (_) { + return []; + } +} + +/* ── EDH Target Ratios ────────────────────────────────────── */ + +const EDH_TARGETS = { + Land: { min: 35, target: 37, max: 40, label: 'Lands' }, + Creature: { min: 20, target: 25, max: 32, label: 'Creatures' }, + Instant: { min: 7, target: 10, max: 14, label: 'Instants' }, + Sorcery: { min: 5, target: 8, max: 12, label: 'Sorceries' }, + Artifact: { min: 6, target: 10, max: 15, label: 'Artifacts' }, + Enchantment: { min: 5, target: 8, max: 12, label: 'Enchantments' }, + Planeswalker: { min: 0, target: 2, max: 5, label: 'Planeswalkers' }, +}; + +/** Map a card's type_line to one of the canonical groups. */ +function getCardTypeGroup(typeLine) { + if (!typeLine) return 'Other'; + const t = typeLine.toLowerCase(); + if (t.includes('land')) return 'Land'; + if (t.includes('creature')) return 'Creature'; + if (t.includes('planeswalker')) return 'Planeswalker'; + if (t.includes('instant')) return 'Instant'; + if (t.includes('sorcery')) return 'Sorcery'; + if (t.includes('artifact')) return 'Artifact'; + if (t.includes('enchantment')) return 'Enchantment'; + return 'Other'; +} + +/** Return a ratio progress status string. */ +function ratioStatus(count, min, max) { + if (count === 0 && min === 0) return 'neutral'; + if (count < min) return 'low'; + if (count > max) return 'over'; + if (count >= min && count <= max) return 'ok'; + return 'warn'; +} + +/* ── Debounce Helper ──────────────────────────────────────── */ + +function debounce(fn, ms) { + let timer; + return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; +} + +/* ── Color Identity Rendering ────────────────────────────── */ + +const COLOR_NAMES = { W: 'White', U: 'Blue', B: 'Black', R: 'Red', G: 'Green', C: 'Colorless' }; + +function buildColorChip(color) { + const chip = el('span', `deck-color-chip deck-chip-${color}`); + chip.textContent = color; + chip.title = COLOR_NAMES[color] || color; + return chip; +} + +function parseColorIdentity(raw) { + if (!raw) return []; + if (Array.isArray(raw)) return raw; + // "WUBRG" string or comma-separated + if (raw.includes(',')) return raw.split(',').map(c => c.trim().toUpperCase()); + return raw.toUpperCase().split('').filter(c => 'WUBRG'.includes(c)); +} + +/* ── Type Badge ───────────────────────────────────────────── */ + +function buildTypeBadge(typeLine) { + const group = getCardTypeGroup(typeLine || ''); + const badge = el('span', `deck-type-badge deck-type-${group}`, group[0] === 'P' && group !== 'Planeswalker' ? group : group.slice(0, 4)); + badge.title = typeLine || group; + return badge; +} + +/* ── Main DeckBuilder Application ────────────────────────── */ + +const DeckBuilder = { + /* State */ + deckId: null, + deckName: '', + deckCards: [], + deckInfo: null, + analysis: null, + allDecks: [], + searchResults: [], + searchQuery: '', + searchType: '', + searchCmcMin: null, + searchCmcMax: null, + searchColors: new Set(), + edhRecData: [], + edhRecOwnedOnly: false, + commanderScryfallId: null, + newCommanderScryfallId: null, + newCommanderName: '', + _saveTimer: null, + _activeTypeFilter: null, + pplxConfigured: false, + aiGenCommanderName: '', + _aiGenSuggestions: [], + _aiGenActiveIndex: -1, + _lastGenResult: null, + + /* ── Init ─────────────────────────────────────────────── */ + + async init() { + ToastManager.init(); + CardPreview.init(); + ConfirmDialog.init(); + + this._bindTopBar(); + this._bindSearch(); + this._bindQuickAdd(); + this._bindNewDeckModal(); + this._bindCollapsible(); + this._bindRightColumn(); + this._bindAIPanels(); + + await this.loadDecks(); + this._checkPplxStatus(); + + // Restore last deck from localStorage + const lastId = localStorage.getItem('deck-builder-last-deck'); + if (lastId) { + this.deckId = parseInt(lastId, 10); + qs('#deck-selector').value = this.deckId; + await this.loadDeck(this.deckId); + } + }, + + /* ── Top Bar Bindings ─────────────────────────────────── */ + + _bindTopBar() { + const selector = qs('#deck-selector'); + selector.addEventListener('change', async (e) => { + const id = parseInt(e.target.value, 10); + if (!isNaN(id)) { + this.deckId = id; + await this.loadDeck(id); + } + }); + + const nameInput = qs('#deck-name-input'); + nameInput.addEventListener('input', () => this._scheduleSaveName()); + nameInput.addEventListener('change', () => this._saveDeckName()); + + qs('#deck-new-btn').addEventListener('click', () => this._openNewDeckModal()); + qs('#deck-empty-new-btn').addEventListener('click', () => this._openNewDeckModal()); + + qs('#deck-export-btn').addEventListener('click', () => this._exportDck()); + qs('#deck-export-sim-btn').addEventListener('click', () => this._exportDckToSim()); + qs('#deck-sim-btn').addEventListener('click', () => this._exportToSim()); + if (qs('#deck-export-game-btn')) { + qs('#deck-export-game-btn').addEventListener('click', () => this._exportToLanClient()); + } + qs('#deck-import-btn').addEventListener('click', () => this._openImportModal()); + qs('#deck-delete-btn').addEventListener('click', () => this._deleteDeck()); + qs('#deck-clear-all-btn').addEventListener('click', () => this._clearAllDecks()); + + // Import modal wiring + this._initImportModal(); + + // Commander portrait click to change commander + qs('#deck-commander-portrait').addEventListener('click', () => { + if (!this.deckId) { ToastManager.show('Create a deck first.', 'warning'); return; } + this._openNewDeckModal(true); + }); + }, + + _scheduleSaveName() { + clearTimeout(this._saveTimer); + this._saveTimer = setTimeout(() => this._saveDeckName(), 1200); + }, + + async _saveDeckName() { + if (!this.deckId) return; + const name = qs('#deck-name-input').value.trim(); + if (!name || name === this.deckName) return; + try { + await apiPut(`/api/decks/${this.deckId}`, { name }); + this.deckName = name; + this._refreshDeckSelector(); + } catch (err) { + ToastManager.show(`Failed to rename deck: ${err.message}`, 'error'); + } + }, + + async _deleteDeck() { + if (!this.deckId) { ToastManager.show('No deck selected.', 'warning'); return; } + const ok = await ConfirmDialog.show({ + title: 'Delete Deck', + message: 'Are you sure you want to delete "' + (this.deckName || 'this deck') + '"? This cannot be undone.', + okLabel: 'Delete', + }); + if (!ok) return; + try { + await apiDelete('/api/decks/' + this.deckId); + ToastManager.show('Deck deleted.', 'success'); + this.deckId = null; + this.deckInfo = null; + this.deckName = ''; + this.deckCards = []; + localStorage.removeItem('deck-builder-last-deck'); + qs('#deck-delete-btn').style.display = 'none'; + await this.loadDecks(); + qs('#deck-selector').value = ''; + qs('#deck-name-input').value = ''; + qs('#deck-card-groups').innerHTML = ''; + } catch (err) { + ToastManager.show('Failed to delete deck: ' + err.message, 'error'); + } + }, + + async _clearAllDecks() { + if (!this.allDecks.length) { ToastManager.show('No decks to clear.', 'warning'); return; } + const ok = await ConfirmDialog.show({ + title: 'Clear All Decks', + message: 'Delete all ' + this.allDecks.length + ' decks? This cannot be undone.', + okLabel: 'Delete All', + }); + if (!ok) return; + try { + await apiDelete('/api/decks'); + ToastManager.show('All decks cleared.', 'success'); + this.deckId = null; + this.deckInfo = null; + this.deckName = ''; + this.deckCards = []; + this.allDecks = []; + localStorage.removeItem('deck-builder-last-deck'); + qs('#deck-delete-btn').style.display = 'none'; + this._renderDeckSelector(); + qs('#deck-selector').value = ''; + qs('#deck-name-input').value = ''; + qs('#deck-card-groups').innerHTML = ''; + } catch (err) { + ToastManager.show('Failed to clear decks: ' + err.message, 'error'); + } + }, + + /* ── Deck Loading ─────────────────────────────────────── */ + + async loadDecks() { + try { + const resp = await apiGet('/api/decks'); + this.allDecks = Array.isArray(resp) ? resp : (resp.decks || []); + this._renderDeckSelector(); + } catch (_) { + this.allDecks = []; + } + }, + + _renderDeckSelector() { + const sel = qs('#deck-selector'); + const current = sel.value; + sel.innerHTML = ''; + for (const d of this.allDecks) { + const opt = document.createElement('option'); + opt.value = d.id; + opt.textContent = `${d.name}${d.commander_name ? ' (' + d.commander_name + ')' : ''}`; + sel.appendChild(opt); + } + sel.value = current; + }, + + _refreshDeckSelector() { + const idx = this.allDecks.findIndex(d => d.id === this.deckId); + if (idx >= 0) { + this.allDecks[idx].name = this.deckName; + this._renderDeckSelector(); + qs('#deck-selector').value = this.deckId; + } + }, + + async loadDeck(id) { + if (!id) return; + this.deckId = id; + localStorage.setItem('deck-builder-last-deck', id); + + // Show loading skeletons + this._showCardGroupsLoading(); + + try { + // Parallel fetch: deck info + cards + const [info, cards] = await Promise.all([ + apiGet(`/api/decks/${id}`), + apiGet(`/api/decks/${id}/cards`), + ]); + this.deckInfo = info; + this.deckName = info.name; + this.deckCards = Array.isArray(cards) ? cards : (cards.cards || []); + this.commanderScryfallId = this.deckCards.find(c => c.is_commander)?.scryfall_id || null; + + this._renderTopBar(); + this._renderCardGroups(); + this._showDeckControls(); + + // Load analysis for ratio dashboard (non-blocking) + this._loadAnalysis(); + + // Refresh search results with new deck context + if (this.searchQuery) this._runSearch(); + + // Enable AI research button + this._updateAIResearchBtn(); + + } catch (err) { + ToastManager.show(`Failed to load deck: ${err.message}`, 'error'); + this._renderCardGroups(); // show empty state + } + }, + + _renderTopBar() { + const info = this.deckInfo; + if (!info) return; + + qs('#deck-name-input').value = info.name || ''; + + // Commander label + const cmdLabel = qs('#deck-commander-label'); + if (info.commander_name) { + cmdLabel.textContent = info.commander_name; + } else { + cmdLabel.textContent = 'No commander selected'; + } + + // Commander portrait + const img = qs('#deck-commander-img'); + const placeholder = qs('.deck-commander-portrait-placeholder'); + const portrait = qs('#deck-commander-portrait'); + const cmdCard = this.deckCards.find(c => c.is_commander); + if (cmdCard && cmdCard.scryfall_id) { + img.src = `https://api.scryfall.com/cards/${cmdCard.scryfall_id}?format=image&version=small`; + img.style.display = 'block'; + placeholder.style.display = 'none'; + this.commanderScryfallId = cmdCard.scryfall_id; + + // Attach card preview hover to commander label and portrait + CardPreview.attach(cmdLabel, cmdCard.scryfall_id); + CardPreview.attach(portrait, cmdCard.scryfall_id); + } else { + img.style.display = 'none'; + placeholder.style.display = ''; + } + + // Color chips + const chipsEl = qs('#deck-color-chips'); + chipsEl.innerHTML = ''; + const colors = parseColorIdentity(info.color_identity); + for (const c of colors) { + chipsEl.appendChild(buildColorChip(c)); + } + + // Total count + this._updateCountDisplay(); + + // Selector + qs('#deck-selector').value = info.id; + }, + + _updateCountDisplay() { + const total = this.deckCards.reduce((s, c) => s + (c.quantity || 1), 0); + const countEl = qs('#deck-total-count'); + countEl.textContent = total; + countEl.className = 'deck-count-num' + (total > 100 ? ' over-limit' : total === 100 ? ' at-limit' : ''); + + const footerTotal = qs('#deck-footer-total'); + if (footerTotal) footerTotal.textContent = total; + + const owned = this.deckCards + .filter(c => (c.owned_quantity || 0) >= (c.quantity || 1)) + .reduce((s, c) => s + (c.quantity || 1), 0); + const footerOwned = qs('#deck-footer-owned'); + if (footerOwned) footerOwned.textContent = owned; + + const nonLands = this.deckCards.filter(c => getCardTypeGroup(c.type_line) !== 'Land'); + const avgCmc = nonLands.length > 0 + ? (nonLands.reduce((s, c) => s + (c.cmc || 0) * (c.quantity || 1), 0) / + nonLands.reduce((s, c) => s + (c.quantity || 1), 0)).toFixed(1) + : '0.0'; + const footerCmc = qs('#deck-footer-avg-cmc'); + if (footerCmc) footerCmc.textContent = avgCmc; + }, + + _showDeckControls() { + qs('#deck-quickadd-row').style.display = ''; + qs('#deck-stats-footer').style.display = ''; + qs('#deck-delete-btn').style.display = this.deckId ? '' : 'none'; + }, + + /* ── Card Groups Rendering ────────────────────────────── */ + + _showCardGroupsLoading() { + const container = qs('#deck-card-groups'); + container.innerHTML = ''; + for (let i = 0; i < 5; i++) { + const skel = el('div', 'deck-skeleton deck-skeleton-row'); + container.appendChild(skel); + } + }, + + _renderCardGroups() { + const container = qs('#deck-card-groups'); + container.innerHTML = ''; + + if (!this.deckId || !this.deckCards || this.deckCards.length === 0) { + const empty = el('div', 'deck-empty-state deck-empty-state-lg'); + const icon = el('div', 'deck-empty-icon', '⚔'); + const text = el('div', 'deck-empty-text', this.deckId + ? 'Your deck is empty. Add cards from the search panel.' + : 'Create or select a deck to start building.'); + const btn = el('button', 'deck-btn deck-btn-accent'); + btn.textContent = '+ New Deck'; + btn.addEventListener('click', () => this._openNewDeckModal()); + empty.appendChild(icon); + empty.appendChild(text); + if (!this.deckId) empty.appendChild(btn); + container.appendChild(empty); + return; + } + + // Separate commander(s) from the rest + const commanders = this.deckCards.filter(c => c.is_commander); + const nonCommanders = this.deckCards.filter(c => !c.is_commander); + + // Render commander section first + if (commanders.length > 0) { + const cmdSection = this._buildSection('Commander', commanders, true); + container.appendChild(cmdSection); + } + + // Group remaining cards by type + const groups = {}; + for (const card of nonCommanders) { + const group = getCardTypeGroup(card.type_line); + if (!groups[group]) groups[group] = []; + groups[group].push(card); + } + + const ORDER = ['Creature', 'Land', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Other']; + + for (const type of ORDER) { + if (!groups[type] || groups[type].length === 0) continue; + const sectionEl = this._buildSection(type, groups[type]); + container.appendChild(sectionEl); + } + + // Update meta label + const metaEl = qs('#deck-card-list-meta'); + const total = this.deckCards.reduce((s, c) => s + (c.quantity || 1), 0); + metaEl.textContent = `${this.deckCards.length} unique · ${total} total`; + + this._updateCountDisplay(); + }, + + _buildSection(type, cards, isCommanderSection) { + const target = isCommanderSection ? null : EDH_TARGETS[type]; + const count = cards.reduce((s, c) => s + (c.quantity || 1), 0); + + const section = el('div', `deck-section${isCommanderSection ? ' deck-section-commander' : ''}`); + section.dataset.type = type; + + // Section header + const header = el('div', 'deck-section-header'); + + const arrow = el('span', 'deck-section-toggle', '▼'); + + const sectionLabel = isCommanderSection ? '⚔ Commander' : (type === 'Other' ? 'Other / Misc' : `${type}s`); + const nameLbl = el('span', 'deck-section-name', sectionLabel); + + let countText = `${count}`; + if (target) countText += ` / ~${target.target}`; + const countLbl = el('span', 'deck-section-count', countText); + + header.appendChild(arrow); + header.appendChild(nameLbl); + header.appendChild(countLbl); + + // Progress bar (skip for commander section) + if (!isCommanderSection) { + const progress = el('div', 'deck-section-progress'); + const fill = el('div', 'deck-section-progress-fill'); + if (target) { + const pct = Math.min(100, Math.round((count / target.max) * 100)); + fill.style.width = `${pct}%`; + fill.classList.add(`status-${ratioStatus(count, target.min, target.max)}`); + } else { + fill.style.width = '50%'; + fill.classList.add('status-neutral'); + } + progress.appendChild(fill); + header.appendChild(progress); + } + + // Body + const body = el('div', 'deck-section-body'); + // Set an explicit max-height so CSS transition works + body.style.maxHeight = '9999px'; + + for (const card of cards) { + body.appendChild(this._buildCardRow(card)); + } + + header.addEventListener('click', () => { + const collapsed = body.classList.toggle('collapsed'); + arrow.classList.toggle('collapsed', collapsed); + if (!collapsed) { + body.style.maxHeight = '9999px'; + } else { + body.style.maxHeight = '0px'; + } + }); + + section.appendChild(header); + section.appendChild(body); + return section; + }, + + _buildCardRow(card) { + const isCmd = card.is_commander; + const row = el('div', `deck-card-row${isCmd ? ' is-commander' : ''}`); + row.dataset.cardId = card.id; + + // Owned dot + const ownedDot = el('span', `deck-card-owned-dot${(card.owned_quantity || 0) > 0 ? '' : ' unowned'}`); + ownedDot.title = `Owned: ${card.owned_quantity || 0}`; + row.appendChild(ownedDot); + + // Mana cost (before name) + if (card.mana_cost) { + const manaEl = buildManaCost(card.mana_cost); + manaEl.className = 'deck-card-row-mana'; + row.appendChild(manaEl); + } + + // Name (hoverable for card preview) + const nameEl = el('span', 'deck-card-row-name', card.card_name); + const hoverSfId = card.scryfall_id || (isCmd ? this.commanderScryfallId : null); + if (hoverSfId) CardPreview.attach(nameEl, hoverSfId); + row.appendChild(nameEl); + + // Role chip (only for non-commander) + if (!isCmd) { + const role = card.role_tag || ''; + const roleChip = el('span', 'deck-card-role-chip', role || 'role'); + roleChip.title = 'Click to set role'; + roleChip.addEventListener('click', () => this._setRoleTag(card)); + row.appendChild(roleChip); + } + + // Quantity controls (not for commander) + if (!isCmd) { + const qtyWrap = el('div', 'deck-qty-controls'); + const minusBtn = el('button', 'deck-qty-btn', '−'); + const qtyNum = el('span', 'deck-qty-num', String(card.quantity || 1)); + const plusBtn = el('button', 'deck-qty-btn', '+'); + + minusBtn.addEventListener('click', () => this._changeQty(card, -1)); + plusBtn.addEventListener('click', () => this._changeQty(card, +1)); + + qtyWrap.appendChild(minusBtn); + qtyWrap.appendChild(qtyNum); + qtyWrap.appendChild(plusBtn); + row.appendChild(qtyWrap); + } + + // Commander badge (after other controls, at end of row) + if (isCmd) { + const cmdBadge = el('span', 'deck-card-commander-badge', 'Commander'); + row.appendChild(cmdBadge); + } + + // Remove button + const removeBtn = el('button', 'deck-card-remove-btn', '✕'); + removeBtn.title = 'Remove from deck'; + removeBtn.addEventListener('click', () => this._removeCard(card)); + row.appendChild(removeBtn); + + return row; + }, + + async _changeQty(card, delta) { + const newQty = (card.quantity || 1) + delta; + if (newQty < 1) { + await this._removeCard(card); + return; + } + try { + await apiPatch(`/api/decks/${this.deckId}/cards/${card.id}`, { quantity: newQty }); + card.quantity = newQty; + // Update the row's quantity display without full re-render + const row = qs(`[data-card-id="${card.id}"]`); + if (row) { + const num = qs('.deck-qty-num', row); + if (num) num.textContent = newQty; + } + this._updateCountDisplay(); + this._updateRatioBar(getCardTypeGroup(card.type_line)); + } catch (err) { + ToastManager.show(`Failed to update quantity: ${err.message}`, 'error'); + } + }, + + async _removeCard(card) { + try { + await apiDelete(`/api/decks/${this.deckId}/cards/${card.id}`); + this.deckCards = this.deckCards.filter(c => c.id !== card.id); + this._renderCardGroups(); + this._loadAnalysis(); + ToastManager.show(`Removed ${card.card_name}`, 'info'); + } catch (err) { + ToastManager.show(`Failed to remove: ${err.message}`, 'error'); + } + }, + + async _setRoleTag(card) { + const roles = ['ramp', 'draw', 'removal', 'wipe', 'tutor', 'protection', 'win-con', 'synergy', 'value', 'beatdown', '']; + const current = card.role_tag || ''; + const currentIdx = roles.indexOf(current); + const next = roles[(currentIdx + 1) % roles.length]; + + try { + await apiPatch(`/api/decks/${this.deckId}/cards/${card.id}`, { role_tag: next }); + card.role_tag = next; + const row = qs(`[data-card-id="${card.id}"]`); + if (row) { + const chip = qs('.deck-card-role-chip', row); + if (chip) chip.textContent = next || 'role'; + } + } catch (err) { + ToastManager.show(`Failed to set role: ${err.message}`, 'error'); + } + }, + + /* ── Adding Cards ─────────────────────────────────────── */ + + async addCardToDeck(scryfallId, cardName, quantity = 1, isCommander = false) { + if (!this.deckId) { + ToastManager.show('Select or create a deck first.', 'warning'); + return; + } + try { + await apiPost(`/api/decks/${this.deckId}/cards`, { + scryfall_id: scryfallId, + card_name: cardName || '', + quantity, + is_commander: isCommander ? 1 : 0, + }); + // Reload deck cards + const _cardsResp = await apiGet(`/api/decks/${this.deckId}/cards`); + this.deckCards = Array.isArray(_cardsResp) ? _cardsResp : (_cardsResp.cards || []); + this._renderCardGroups(); + this._loadAnalysis(); + ToastManager.show(`Added ${cardName}`, 'success'); + // Refresh search results to update in-deck status + if (this.searchQuery) this._refreshSearchInDeckStatus(); + } catch (err) { + if (err.message.includes('already in deck') || err.message.includes('already exists')) { + ToastManager.show(`${cardName} is already in your deck`, 'warning'); + } else { + ToastManager.show(`Failed to add ${cardName}: ${err.message}`, 'error'); + } + } + }, + + _refreshSearchInDeckStatus() { + const inDeckIds = new Set(this.deckCards.map(c => c.scryfall_id)); + qsa('.deck-search-row', qs('#deck-search-results')).forEach(row => { + const id = row.dataset.scryfallId; + if (inDeckIds.has(id)) { + row.classList.add('in-deck'); + const addBtn = qs('.deck-search-row-add', row); + if (addBtn) addBtn.textContent = '✓'; + } else { + row.classList.remove('in-deck'); + const addBtn = qs('.deck-search-row-add', row); + if (addBtn) addBtn.textContent = '+'; + } + }); + }, + + /* ── Collection Search ────────────────────────────────── */ + + _bindSearch() { + const bar = qs('#deck-search-bar'); + const typeFilter = qs('#deck-type-filter'); + const cmcMin = qs('#deck-cmc-min'); + const cmcMax = qs('#deck-cmc-max'); + + const debouncedSearch = debounce(() => this._runSearch(), 350); + + bar.addEventListener('input', (e) => { + this.searchQuery = e.target.value.trim(); + debouncedSearch(); + }); + + typeFilter.addEventListener('change', (e) => { + this.searchType = e.target.value; + this._runSearch(); + }); + + cmcMin.addEventListener('change', (e) => { + this.searchCmcMin = e.target.value !== '' ? parseInt(e.target.value, 10) : null; + debouncedSearch(); + }); + + cmcMax.addEventListener('change', (e) => { + this.searchCmcMax = e.target.value !== '' ? parseInt(e.target.value, 10) : null; + debouncedSearch(); + }); + + // Color filter pills + qs('#deck-color-filter').addEventListener('click', (e) => { + const btn = e.target.closest('.deck-color-pip'); + if (!btn) return; + const color = btn.dataset.color; + if (this.searchColors.has(color)) { + this.searchColors.delete(color); + btn.classList.remove('active'); + } else { + this.searchColors.add(color); + btn.classList.add('active'); + } + debouncedSearch(); + }); + + qs('#deck-add-all-owned-btn').addEventListener('click', () => this._addAllMatching()); + }, + + async _runSearch() { + const resultsEl = qs('#deck-search-results'); + + if (!this.searchQuery && !this.searchType && this.searchColors.size === 0 && this.searchCmcMin === null && this.searchCmcMax === null) { + resultsEl.innerHTML = ''; + const empty = el('div', 'deck-empty-state'); + empty.appendChild(el('div', 'deck-empty-icon', '🃏')); + empty.appendChild(el('div', 'deck-empty-text', 'Search your collection to add cards')); + resultsEl.appendChild(empty); + qs('#deck-search-count').textContent = '0 results'; + qs('#deck-add-all-owned-btn').style.display = 'none'; + return; + } + + // Show loading + resultsEl.innerHTML = '
Searching...
'; + + try { + let params = ['pageSize=200']; + if (this.searchQuery) params.push(`q=${encodeURIComponent(this.searchQuery)}`); + if (this.deckId) params.push(`deck_id=${this.deckId}`); + if (this.searchType) params.push(`types=${encodeURIComponent(this.searchType)}`); + if (this.searchCmcMin !== null) params.push(`cmcMin=${this.searchCmcMin}`); + if (this.searchCmcMax !== null) params.push(`cmcMax=${this.searchCmcMax}`); + if (this.searchColors.size > 0) params.push(`colors=${[...this.searchColors].join(',')}`); + + const url = `/api/collection?${params.join('&')}`; + const _collResp = await apiGet(url); + let cards = Array.isArray(_collResp) ? _collResp : (_collResp.items || []); + + // Client-side fallback filters + if (this.searchType) { + cards = cards.filter(c => getCardTypeGroup(c.type_line || '') === this.searchType); + } + if (this.searchCmcMin !== null) { + cards = cards.filter(c => (c.cmc || 0) >= this.searchCmcMin); + } + if (this.searchCmcMax !== null) { + cards = cards.filter(c => (c.cmc || 0) <= this.searchCmcMax); + } + + this.searchResults = cards; + this._renderSearchResults(cards); + + } catch (err) { + resultsEl.innerHTML = `
${err.message}
`; + } + }, + + _renderSearchResults(cards) { + const resultsEl = qs('#deck-search-results'); + resultsEl.innerHTML = ''; + + qs('#deck-search-count').textContent = `${cards.length} result${cards.length !== 1 ? 's' : ''}`; + + if (cards.length === 0) { + const empty = el('div', 'deck-empty-state'); + empty.appendChild(el('div', 'deck-empty-icon', '🔍')); + empty.appendChild(el('div', 'deck-empty-text', 'No cards found in your collection')); + resultsEl.appendChild(empty); + qs('#deck-add-all-owned-btn').style.display = 'none'; + return; + } + + const inDeckIds = new Set(this.deckCards.map(c => c.scryfall_id)); + let hasOwned = false; + + for (const card of cards) { + const inDeck = inDeckIds.has(card.scryfall_id); + const owned = (card.quantity || card.owned_quantity || 0) > 0; + if (owned) hasOwned = true; + + const row = el('div', `deck-search-row${inDeck ? ' in-deck' : ''}`); + row.dataset.scryfallId = card.scryfall_id; + + const nameEl = el('span', 'deck-search-row-name', card.card_name || card.name); + CardPreview.attach(nameEl, card.scryfall_id); + + const manaEl = buildManaCost(card.mana_cost); + manaEl.className = 'deck-search-row-mana'; + + const ownedEl = el('span', `deck-search-row-owned${owned ? ' has-owned' : ''}`, + owned ? `×${card.quantity || card.owned_quantity}` : '—'); + ownedEl.title = `Owned: ${card.quantity || card.owned_quantity || 0}`; + + const addBtn = el('button', 'deck-search-row-add', inDeck ? '✓' : '+'); + addBtn.title = inDeck ? 'Already in deck' : 'Add to deck'; + addBtn.addEventListener('click', () => { + this.addCardToDeck(card.scryfall_id, card.card_name || card.name); + }); + + row.appendChild(nameEl); + row.appendChild(manaEl); + row.appendChild(ownedEl); + row.appendChild(addBtn); + resultsEl.appendChild(row); + } + + // Show bulk-add button if there are owned cards + const bulkBtn = qs('#deck-add-all-owned-btn'); + bulkBtn.style.display = hasOwned ? '' : 'none'; + }, + + async _addAllMatching() { + const owned = this.searchResults.filter(c => (c.quantity || c.owned_quantity || 0) > 0); + if (owned.length === 0) { + ToastManager.show('No owned cards in search results.', 'warning'); + return; + } + + const confirmed = await ConfirmDialog.show({ + title: 'Add All Owned', + message: `Add all ${owned.length} owned matching card(s) to your deck?`, + changes: [{ label: 'Cards to add', before: 0, after: owned.length }], + }); + if (!confirmed) return; + + let added = 0, skipped = 0; + for (const card of owned) { + try { + await apiPost(`/api/decks/${this.deckId}/cards`, { + scryfall_id: card.scryfall_id, + card_name: card.card_name || card.name || '', + quantity: 1, + }); + added++; + } catch (_) { + skipped++; + } + } + + const _cr1 = await apiGet(`/api/decks/${this.deckId}/cards`); + this.deckCards = Array.isArray(_cr1) ? _cr1 : (_cr1.cards || []); + this._renderCardGroups(); + this._loadAnalysis(); + this._refreshSearchInDeckStatus(); + ToastManager.show(`Bulk added ${added} cards${skipped > 0 ? ` (${skipped} skipped)` : ''}`, 'success'); + }, + + /* ── Quick Add ────────────────────────────────────────── */ + + _bindQuickAdd() { + const input = qs('#deck-quickadd-input'); + const dropdown = qs('#deck-quickadd-dropdown'); + const addBtn = qs('#deck-quickadd-btn'); + + let _activeIndex = -1; + let _suggestions = []; + + const debouncedAC = debounce(async (q) => { + if (!q || q.length < 2) { dropdown.style.display = 'none'; return; } + const names = await scryfallAutocomplete(q); + _suggestions = names.slice(0, 8); + _activeIndex = -1; + this._renderQuickAddDropdown(_suggestions, _activeIndex, dropdown); + }, 280); + + input.addEventListener('input', (e) => debouncedAC(e.target.value.trim())); + + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { + _activeIndex = Math.min(_activeIndex + 1, _suggestions.length - 1); + this._renderQuickAddDropdown(_suggestions, _activeIndex, dropdown); + e.preventDefault(); + } else if (e.key === 'ArrowUp') { + _activeIndex = Math.max(_activeIndex - 1, -1); + this._renderQuickAddDropdown(_suggestions, _activeIndex, dropdown); + e.preventDefault(); + } else if (e.key === 'Enter') { + e.preventDefault(); + const name = _activeIndex >= 0 ? _suggestions[_activeIndex] : input.value.trim(); + if (name) this._quickAddByName(name, input, dropdown); + } else if (e.key === 'Escape') { + dropdown.style.display = 'none'; + } + }); + + document.addEventListener('click', (e) => { + if (!input.contains(e.target) && !dropdown.contains(e.target)) { + dropdown.style.display = 'none'; + } + }); + + addBtn.addEventListener('click', () => { + const name = input.value.trim(); + if (name) this._quickAddByName(name, input, dropdown); + }); + + dropdown.addEventListener('click', (e) => { + const item = e.target.closest('.deck-quickadd-item'); + if (item) { + const name = item.dataset.name; + this._quickAddByName(name, input, dropdown); + } + }); + }, + + _renderQuickAddDropdown(suggestions, activeIndex, dropdown) { + dropdown.innerHTML = ''; + if (suggestions.length === 0) { dropdown.style.display = 'none'; return; } + for (let i = 0; i < suggestions.length; i++) { + const name = suggestions[i]; + const item = el('div', `deck-quickadd-item${i === activeIndex ? ' active' : ''}`); + item.dataset.name = name; + item.appendChild(el('span', 'deck-quickadd-item-name', name)); + dropdown.appendChild(item); + } + dropdown.style.display = 'block'; + }, + + async _quickAddByName(name, input, dropdown) { + if (!this.deckId) { + ToastManager.show('Create or select a deck first.', 'warning'); + return; + } + dropdown.style.display = 'none'; + input.value = ''; + + // Scryfall fuzzy lookup to get the scryfall_id + const card = await scryfallFuzzySearch(name); + if (!card) { + ToastManager.show(`Card "${name}" not found on Scryfall`, 'error'); + return; + } + await this.addCardToDeck(card.id, card.name); + }, + + /* ── Analysis & Ratio Dashboard ──────────────────────── */ + + async _loadAnalysis() { + if (!this.deckId) return; + try { + this.analysis = await apiGet(`/api/decks/${this.deckId}/analysis`); + this._renderRatioDashboard(); + this._renderShortfalls(); + } catch (_) { + this._renderRatioDashboard(true); + } + }, + + _renderRatioDashboard(failed = false) { + const barsEl = qs('#deck-ratio-bars'); + barsEl.innerHTML = ''; + + if (failed || !this.analysis) { + barsEl.appendChild(el('div', 'deck-ratio-placeholder', failed ? 'Analysis unavailable' : 'Load a deck to see ratios')); + return; + } + + const counts = this.analysis.counts_by_type || {}; + + for (const [type, tgt] of Object.entries(EDH_TARGETS)) { + const count = counts[type] || 0; + const maxRange = tgt.max; + const status = ratioStatus(count, tgt.min, tgt.max); + const pct = maxRange > 0 ? Math.min(100, (count / maxRange) * 100) : 0; + + const row = el('div', `deck-ratio-bar-row${this._activeTypeFilter === type ? ' active-filter' : ''}`); + row.dataset.type = type; + row.title = `${tgt.label}: ${count} (target: ${tgt.min}–${tgt.max})`; + + row.addEventListener('click', () => { + if (this._activeTypeFilter === type) { + this._activeTypeFilter = null; + qs('#deck-type-filter').value = ''; + } else { + this._activeTypeFilter = type; + qs('#deck-type-filter').value = type; + } + this._runSearch(); + this._renderRatioDashboard(); + }); + + const label = el('span', 'deck-ratio-bar-label', tgt.label); + const track = el('div', 'deck-ratio-bar-track'); + const fill = el('div', `deck-ratio-bar-fill status-${status}`); + fill.style.width = `${pct}%`; + + // Min/max indicator marks + if (maxRange > 0) { + const minMark = el('div', 'deck-ratio-bar-target-min'); + minMark.style.left = `${(tgt.min / maxRange) * 100}%`; + const maxMark = el('div', 'deck-ratio-bar-target-max'); + maxMark.style.left = `${Math.min(100, (tgt.max / maxRange) * 100)}%`; + track.appendChild(minMark); + track.appendChild(maxMark); + } + + track.appendChild(fill); + + const nums = el('span', 'deck-ratio-bar-nums'); + const cur = el('span', 'deck-ratio-current', String(count)); + nums.appendChild(cur); + nums.appendChild(document.createTextNode(` / ${tgt.target}`)); + + row.appendChild(label); + row.appendChild(track); + row.appendChild(nums); + barsEl.appendChild(row); + } + }, + + _updateRatioBar(type) { + // Lightweight update: recalculate count for one type and re-draw + const counts = {}; + for (const card of this.deckCards) { + const g = getCardTypeGroup(card.type_line); + counts[g] = (counts[g] || 0) + (card.quantity || 1); + } + if (this.analysis) { + this.analysis.counts_by_type = counts; + this._renderRatioDashboard(); + } + }, + + _renderShortfalls() { + const sfEl = qs('#deck-shortfalls'); + sfEl.innerHTML = ''; + const fixActions = qs('#deck-fix-actions'); + + if (!this.analysis) { + sfEl.appendChild(el('div', 'deck-ratio-placeholder', 'No analysis available')); + fixActions.style.display = 'none'; + return; + } + + const counts = this.analysis.counts_by_type || {}; + let hasIssues = false; + + for (const [type, tgt] of Object.entries(EDH_TARGETS)) { + const count = counts[type] || 0; + const status = ratioStatus(count, tgt.min, tgt.max); + if (status === 'ok' || status === 'neutral') continue; + + hasIssues = true; + const row = el('div', 'deck-shortfall-row'); + const icon = el('span', 'deck-shortfall-icon', status === 'low' ? '⬇' : status === 'over' ? '⬆' : '⚠'); + const text = el('span', 'deck-shortfall-text', `${tgt.label} (${count})`); + const delta = tgt.target - count; + const deltaEl = el('span', `deck-shortfall-delta ${delta > 0 ? 'needs-more' : 'over-count'}`); + deltaEl.textContent = delta > 0 ? `+${delta} needed` : `${delta} over`; + + row.appendChild(icon); + row.appendChild(text); + row.appendChild(deltaEl); + sfEl.appendChild(row); + } + + if (!hasIssues) { + const ok = el('div', 'deck-shortfall-row'); + ok.appendChild(el('span', 'deck-shortfall-icon', '✓')); + ok.appendChild(el('span', 'deck-shortfall-text deck-text-success', 'Ratios look good!')); + sfEl.appendChild(ok); + } + + fixActions.style.display = hasIssues ? '' : 'none'; + }, + + /* ── Fix My Ratios ────────────────────────────────────── */ + + _bindRightColumn() { + qs('#deck-autosuggest-btn').addEventListener('click', () => this._autoSuggestFromCollection()); + qs('#deck-edhrec-refresh-btn').addEventListener('click', () => this._loadEdhRecs()); + qs('#deck-edhrec-owned-only').addEventListener('change', (e) => { + this.edhRecOwnedOnly = e.target.checked; + this._loadEdhRecs(); + }); + qs('#deck-edhrec-bulk-owned-btn').addEventListener('click', () => this._bulkAddEdhRecOwned()); + }, + + async _autoSuggestFromCollection() { + if (!this.deckId) { + ToastManager.show('Load a deck first.', 'warning'); + return; + } + const suggestEl = qs('#deck-fix-suggestions'); + suggestEl.style.display = 'block'; + suggestEl.innerHTML = '
Finding suggestions...
'; + + try { + const recsResp = await apiGet(`/api/decks/${this.deckId}/recommended-from-collection?max_results=20`); + // API returns {grouped: {type: [cards]}, total, shortfall_types} + // Flatten grouped into array for rendering + let recsList = []; + if (recsResp && recsResp.grouped) { + for (const cards of Object.values(recsResp.grouped)) { + recsList.push(...cards); + } + } else if (Array.isArray(recsResp)) { + recsList = recsResp; + } + this._renderFixSuggestions(recsList); + } catch (err) { + suggestEl.innerHTML = `
${err.message}
`; + } + }, + + _renderFixSuggestions(recs) { + const suggestEl = qs('#deck-fix-suggestions'); + suggestEl.innerHTML = ''; + + if (!recs || recs.length === 0) { + suggestEl.appendChild(el('div', 'deck-ratio-placeholder', 'No suggestions found in collection')); + return; + } + + // Group by type + const groups = {}; + for (const r of recs) { + const type = getCardTypeGroup(r.type_line || ''); + if (!groups[type]) groups[type] = []; + groups[type].push(r); + } + + for (const [type, cards] of Object.entries(groups)) { + const group = el('div', 'deck-fix-group'); + + const titleRow = el('div', 'deck-fix-group-title'); + titleRow.appendChild(el('span', null, type)); + const addAllBtn = el('button', 'deck-btn deck-btn-xs deck-btn-ghost', `Add all (${cards.length})`); + addAllBtn.addEventListener('click', () => this._bulkAddSuggestions(cards)); + titleRow.appendChild(addAllBtn); + group.appendChild(titleRow); + + for (const card of cards) { + const row = el('div', 'deck-fix-card'); + + const cardName = card.card_name || card.name || ''; + const nameEl = el('span', 'deck-fix-card-name', cardName); + CardPreview.attach(nameEl, card.scryfall_id); + + const ownedEl = el('span', 'deck-fix-card-owned', `×${card.owned_qty || card.owned_quantity || 0}`); + + const addBtn = el('button', 'deck-btn deck-btn-xs deck-btn-accent', '+'); + addBtn.addEventListener('click', () => this.addCardToDeck(card.scryfall_id, cardName)); + + row.appendChild(nameEl); + row.appendChild(ownedEl); + row.appendChild(addBtn); + group.appendChild(row); + } + + suggestEl.appendChild(group); + } + }, + + async _bulkAddSuggestions(cards) { + if (!this.deckId || cards.length === 0) return; + + const confirmed = await ConfirmDialog.show({ + title: 'Bulk Add Suggestions', + message: `Add ${cards.length} suggested card(s) to the deck?`, + changes: [{ label: 'Cards to add', before: 0, after: cards.length }], + }); + if (!confirmed) return; + + let added = 0, skipped = 0; + for (const card of cards) { + try { + await apiPost(`/api/decks/${this.deckId}/cards`, { + scryfall_id: card.scryfall_id, + card_name: card.card_name || card.name || '', + quantity: 1, + }); + added++; + } catch (_) { + skipped++; + } + } + + const _cr2 = await apiGet(`/api/decks/${this.deckId}/cards`); + this.deckCards = Array.isArray(_cr2) ? _cr2 : (_cr2.cards || []); + this._renderCardGroups(); + this._loadAnalysis(); + ToastManager.show(`Added ${added} cards${skipped > 0 ? ` (${skipped} skipped)` : ''}`, 'success'); + }, + + /* ── EDHREC Recommendations ──────────────────────────── */ + + async _loadEdhRecs() { + if (!this.deckId) return; + const listEl = qs('#deck-edhrec-list'); + listEl.innerHTML = '
Loading EDHREC data...
'; + + try { + const url = `/api/decks/${this.deckId}/edh-recs?only_owned=${this.edhRecOwnedOnly}&max_results=30`; + const edhResp = await apiGet(url); + this.edhRecData = Array.isArray(edhResp) ? edhResp : (edhResp.recommendations || []); + this._renderEdhRecs(); + } catch (err) { + listEl.innerHTML = `
${err.message}
`; + } + }, + + _renderEdhRecs() { + const listEl = qs('#deck-edhrec-list'); + listEl.innerHTML = ''; + const bulkEl = qs('#deck-edhrec-bulk'); + + if (!this.edhRecData || this.edhRecData.length === 0) { + listEl.appendChild(el('div', 'deck-ratio-placeholder', 'No EDHREC recommendations available')); + bulkEl.style.display = 'none'; + return; + } + + const inDeckIds = new Set(this.deckCards.map(c => c.scryfall_id)); + let hasOwned = false; + + for (const card of this.edhRecData) { + const inDeck = inDeckIds.has(card.scryfall_id); + if (card.owned) hasOwned = true; + + const row = el('div', `deck-edhrec-card${inDeck ? ' in-deck' : ''}`); + row.dataset.scryfallId = card.scryfall_id; + + const main = el('div', 'deck-edhrec-card-main'); + + const nameEl = el('span', 'deck-edhrec-card-name', card.name); + CardPreview.attach(nameEl, card.scryfall_id); + + const meta = el('div', 'deck-edhrec-card-meta'); + + const pct = el('span', 'deck-edhrec-pct'); + pct.innerHTML = `${Math.round((card.inclusion_pct || 0) * 100)}% incl.`; + + const syn = el('span', `deck-edhrec-synergy${(card.synergy_score || 0) > 0 ? ' positive' : ''}`); + syn.textContent = `syn: ${card.synergy_score != null ? (card.synergy_score > 0 ? '+' : '') + card.synergy_score.toFixed(2) : 'n/a'}`; + + const typeBadge = buildTypeBadge(card.type_line || ''); + + meta.appendChild(pct); + meta.appendChild(syn); + meta.appendChild(typeBadge); + + if (card.owned) { + const ownedBadge = el('span', 'deck-owned-badge', 'Owned'); + meta.appendChild(ownedBadge); + } + + main.appendChild(nameEl); + main.appendChild(meta); + row.appendChild(main); + + if (!inDeck) { + const addBtn = el('button', 'deck-btn deck-btn-xs deck-btn-accent', '+'); + addBtn.addEventListener('click', () => this.addCardToDeck(card.scryfall_id, card.name)); + row.appendChild(addBtn); + } else { + const inDeckLbl = el('span', 'deck-text-muted', '✓'); + inDeckLbl.style.fontSize = '11px'; + row.appendChild(inDeckLbl); + } + + listEl.appendChild(row); + } + + bulkEl.style.display = hasOwned ? '' : 'none'; + }, + + async _bulkAddEdhRecOwned() { + if (!this.deckId) return; + const inDeckIds = new Set(this.deckCards.map(c => c.scryfall_id)); + const toAdd = this.edhRecData.filter(c => c.owned && !inDeckIds.has(c.scryfall_id)); + + if (toAdd.length === 0) { + ToastManager.show('No new owned cards to add.', 'warning'); + return; + } + + const confirmed = await ConfirmDialog.show({ + title: 'Add All Owned EDHREC Recs', + message: `Add ${toAdd.length} owned recommendation(s) to the deck?`, + changes: [{ label: 'Cards to add', before: 0, after: toAdd.length }], + }); + if (!confirmed) return; + + try { + await apiPost(`/api/decks/${this.deckId}/bulk-add-recommended`, { + source: 'edhrec', + only_owned: true, + respect_ratios: true, + }); + const _cr3 = await apiGet(`/api/decks/${this.deckId}/cards`); + this.deckCards = Array.isArray(_cr3) ? _cr3 : (_cr3.cards || []); + this._renderCardGroups(); + this._loadAnalysis(); + this._renderEdhRecs(); + ToastManager.show(`Added owned EDHREC recommendations`, 'success'); + } catch (err) { + // Fallback: add one by one + let added = 0, skipped = 0; + for (const card of toAdd) { + try { + await apiPost(`/api/decks/${this.deckId}/cards`, { + scryfall_id: card.scryfall_id, + card_name: card.name || '', + quantity: 1, + }); + added++; + } catch (_) { + skipped++; + } + } + const _cr4 = await apiGet(`/api/decks/${this.deckId}/cards`); + this.deckCards = Array.isArray(_cr4) ? _cr4 : (_cr4.cards || []); + this._renderCardGroups(); + this._loadAnalysis(); + this._renderEdhRecs(); + ToastManager.show(`Added ${added}${skipped > 0 ? ` (${skipped} skipped)` : ''}`, 'success'); + } + }, + + /* ── Collapsible Panels ───────────────────────────────── */ + + _bindCollapsible() { + qsa('.deck-panel-header-collapsible').forEach(header => { + const targetId = header.dataset.target; + const body = qs(`#${targetId}`); + const arrow = qs('.deck-collapse-arrow', header); + if (!body) return; + body.style.maxHeight = body.scrollHeight + 'px'; + + header.addEventListener('click', () => { + const isCollapsed = body.classList.toggle('collapsed'); + arrow.classList.toggle('collapsed', isCollapsed); + body.style.maxHeight = isCollapsed ? '0px' : body.scrollHeight + 1000 + 'px'; + }); + }); + }, + + /* ── New Deck Modal ───────────────────────────────────── */ + + _bindNewDeckModal() { + qs('#deck-new-modal-close').addEventListener('click', () => this._closeNewDeckModal()); + qs('#deck-new-cancel').addEventListener('click', () => this._closeNewDeckModal()); + qs('#deck-new-modal').addEventListener('click', (e) => { + if (e.target === qs('#deck-new-modal')) this._closeNewDeckModal(); + }); + qs('#deck-new-create').addEventListener('click', () => this._createNewDeck()); + + qs('#deck-clear-commander-btn').addEventListener('click', () => { + this.newCommanderScryfallId = null; + this.newCommanderName = ''; + qs('#deck-new-commander-selected').style.display = 'none'; + qs('#deck-new-commander-search').value = ''; + }); + + // Commander autocomplete in new deck modal + const cmdSearch = qs('#deck-new-commander-search'); + const cmdDropdown = qs('#deck-new-commander-dropdown'); + let _cmdSuggestions = []; + let _cmdActiveIndex = -1; + + const debouncedCmdSearch = debounce(async (q) => { + if (!q || q.length < 2) { cmdDropdown.style.display = 'none'; return; } + // Search Scryfall for legendary creatures + const cards = await scryfallSearchCards(`${q} is:commander`); + _cmdSuggestions = cards.slice(0, 8); + _cmdActiveIndex = -1; + this._renderCommanderDropdown(_cmdSuggestions, _cmdActiveIndex, cmdDropdown); + }, 350); + + cmdSearch.addEventListener('input', (e) => debouncedCmdSearch(e.target.value.trim())); + + cmdSearch.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { + _cmdActiveIndex = Math.min(_cmdActiveIndex + 1, _cmdSuggestions.length - 1); + this._renderCommanderDropdown(_cmdSuggestions, _cmdActiveIndex, cmdDropdown); + e.preventDefault(); + } else if (e.key === 'ArrowUp') { + _cmdActiveIndex = Math.max(_cmdActiveIndex - 1, -1); + this._renderCommanderDropdown(_cmdSuggestions, _cmdActiveIndex, cmdDropdown); + e.preventDefault(); + } else if (e.key === 'Enter' && _cmdActiveIndex >= 0) { + e.preventDefault(); + const card = _cmdSuggestions[_cmdActiveIndex]; + this._selectCommanderOption(card, cmdSearch, cmdDropdown); + } else if (e.key === 'Escape') { + cmdDropdown.style.display = 'none'; + } + }); + + cmdDropdown.addEventListener('click', (e) => { + const item = e.target.closest('.deck-commander-option'); + if (!item) return; + const idx = parseInt(item.dataset.index, 10); + const card = _cmdSuggestions[idx]; + if (card) this._selectCommanderOption(card, cmdSearch, cmdDropdown); + }); + + document.addEventListener('click', (e) => { + if (!cmdSearch.contains(e.target) && !cmdDropdown.contains(e.target)) { + cmdDropdown.style.display = 'none'; + } + }); + }, + + _renderCommanderDropdown(cards, activeIndex, dropdown) { + dropdown.innerHTML = ''; + if (cards.length === 0) { dropdown.style.display = 'none'; return; } + for (let i = 0; i < cards.length; i++) { + const card = cards[i]; + const item = el('div', `deck-commander-option${i === activeIndex ? ' active' : ''}`); + item.dataset.index = i; + + const nameEl = el('span', 'deck-commander-option-name', card.name); + const colorWrap = el('span', 'deck-commander-option-color'); + const ci = card.color_identity || []; + for (const c of ci) { + colorWrap.appendChild(buildColorChip(c)); + } + + item.appendChild(nameEl); + item.appendChild(colorWrap); + dropdown.appendChild(item); + } + dropdown.style.display = 'block'; + }, + + _selectCommanderOption(card, input, dropdown) { + this.newCommanderScryfallId = card.id; + this.newCommanderName = card.name; + input.value = ''; + dropdown.style.display = 'none'; + + qs('#deck-new-commander-name').textContent = card.name; + qs('#deck-new-commander-selected').style.display = 'flex'; + }, + + _openNewDeckModal(commanderOnly = false) { + const modal = qs('#deck-new-modal'); + this.newCommanderScryfallId = null; + this.newCommanderName = ''; + qs('#deck-new-name').value = commanderOnly ? (this.deckName || '') : ''; + qs('#deck-new-commander-search').value = ''; + qs('#deck-new-commander-selected').style.display = 'none'; + modal.style.display = 'flex'; + if (!commanderOnly) qs('#deck-new-name').focus(); + else qs('#deck-new-commander-search').focus(); + }, + + _closeNewDeckModal() { + qs('#deck-new-modal').style.display = 'none'; + this.newCommanderScryfallId = null; + this.newCommanderName = ''; + }, + + async _createNewDeck() { + const name = qs('#deck-new-name').value.trim(); + if (!name) { + qs('#deck-new-name').focus(); + ToastManager.show('Please enter a deck name.', 'warning'); + return; + } + + const payload = { + name, + commander_scryfall_id: this.newCommanderScryfallId || undefined, + commander_name: this.newCommanderName || undefined, + }; + + try { + const deck = await apiPost('/api/decks', payload); + this.deckId = deck.id; + this.deckName = deck.name; + this._closeNewDeckModal(); + + // Add commander card BEFORE loading deck so it renders correctly + if (this.newCommanderScryfallId) { + try { + await apiPost(`/api/decks/${deck.id}/cards`, { + scryfall_id: this.newCommanderScryfallId, + card_name: this.newCommanderName || '', + quantity: 1, + is_commander: 1, + }); + } catch (cmdErr) { + console.warn('Failed to add commander card:', cmdErr); + } + } + + await this.loadDecks(); + await this.loadDeck(deck.id); + ToastManager.show(`Created deck "${deck.name}"`, 'success'); + } catch (err) { + ToastManager.show(`Failed to create deck: ${err.message}`, 'error'); + } + }, + + /* ── Import ───────────────────────────────────────────── */ + + _importFileText: '', + + _initImportModal() { + const modal = qs('#deck-import-modal'); + const closeModal = () => { modal.style.display = 'none'; }; + qs('#deck-import-modal-close').addEventListener('click', closeModal); + qs('#deck-import-cancel').addEventListener('click', closeModal); + modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); }); + + // Tabs + for (const tab of document.querySelectorAll('.deck-import-tab')) { + tab.addEventListener('click', () => { + document.querySelectorAll('.deck-import-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + const which = tab.dataset.tab; + qs('#deck-import-paste-panel').style.display = which === 'paste' ? '' : 'none'; + qs('#deck-import-file-panel').style.display = which === 'file' ? '' : 'none'; + }); + } + + // File upload + const dropzone = qs('#deck-import-dropzone'); + const fileInput = qs('#deck-import-file'); + dropzone.addEventListener('click', () => fileInput.click()); + dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); }); + dropzone.addEventListener('dragleave', () => dropzone.classList.remove('dragover')); + dropzone.addEventListener('drop', (e) => { + e.preventDefault(); + dropzone.classList.remove('dragover'); + if (e.dataTransfer.files.length) this._handleImportFile(e.dataTransfer.files[0]); + }); + fileInput.addEventListener('change', () => { + if (fileInput.files.length) this._handleImportFile(fileInput.files[0]); + }); + + // Mode radio toggles name row visibility + for (const radio of document.querySelectorAll('input[name="import-mode"]')) { + radio.addEventListener('change', () => { + const mode = document.querySelector('input[name="import-mode"]:checked').value; + qs('#deck-import-new-name-row').style.display = mode === 'new' ? '' : 'none'; + }); + } + + // Go button + qs('#deck-import-go').addEventListener('click', () => this._doImport()); + }, + + _handleImportFile(file) { + const reader = new FileReader(); + reader.onload = () => { + this._importFileText = reader.result; + qs('#deck-import-file-name').textContent = file.name + ' (' + (reader.result.split('\n').length) + ' lines)'; + }; + reader.readAsText(file); + }, + + _openImportModal() { + const modal = qs('#deck-import-modal'); + qs('#deck-import-textarea').value = ''; + qs('#deck-import-name').value = ''; + qs('#deck-import-file-name').textContent = ''; + qs('#deck-import-file').value = ''; + qs('#deck-import-progress').style.display = 'none'; + qs('#deck-import-result').style.display = 'none'; + qs('#deck-import-go').disabled = false; + qs('#deck-import-go').textContent = 'Import'; + this._importFileText = ''; + + // If no deck loaded, default to "new" + if (!this.deckId) { + document.querySelector('input[name="import-mode"][value="new"]').checked = true; + qs('#deck-import-new-name-row').style.display = ''; + } + + modal.style.display = 'flex'; + qs('#deck-import-textarea').focus(); + }, + + async _doImport() { + const activeTab = document.querySelector('.deck-import-tab.active').dataset.tab; + let text = ''; + + if (activeTab === 'paste') { + text = qs('#deck-import-textarea').value.trim(); + } else { + text = this._importFileText; + } + + if (!text) { + ToastManager.show('No decklist to import.', 'warning'); + return; + } + + const mode = document.querySelector('input[name="import-mode"]:checked').value; + const importBtn = qs('#deck-import-go'); + importBtn.disabled = true; + importBtn.textContent = 'Importing...'; + + qs('#deck-import-progress').style.display = 'block'; + qs('#deck-import-progress-bar').style.width = '30%'; + qs('#deck-import-result').style.display = 'none'; + + try { + let result; + + if (mode === 'new') { + const name = qs('#deck-import-name').value.trim(); + const res = await fetch(LAB_API_BASE + '/api/decks/import-new', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, name }), + }); + result = await res.json(); + if (result.error) throw new Error(result.error); + + qs('#deck-import-progress-bar').style.width = '100%'; + this._showImportResult(result); + + // Load the new deck + await this.loadDecks(); + await this.loadDeck(result.deckId); + ToastManager.show('Imported "' + result.deckName + '" (' + result.added + ' cards)', 'success'); + + } else { + // add or replace into current deck + if (!this.deckId) { + ToastManager.show('Select or create a deck first, or use "Create new deck" mode.', 'warning'); + importBtn.disabled = false; + importBtn.textContent = 'Import'; + qs('#deck-import-progress').style.display = 'none'; + return; + } + + const clearFirst = mode === 'replace'; + const res = await fetch(LAB_API_BASE + '/api/decks/' + this.deckId + '/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, clearFirst }), + }); + result = await res.json(); + if (result.error) throw new Error(result.error); + + qs('#deck-import-progress-bar').style.width = '100%'; + this._showImportResult(result); + + // Reload current deck + await this.loadDeck(this.deckId); + const msg = 'Imported ' + result.added + ' cards' + (result.failed > 0 ? ' (' + result.failed + ' not found)' : ''); + ToastManager.show(msg, result.failed > 0 ? 'warning' : 'success'); + } + + // Close modal after short delay + setTimeout(() => { + qs('#deck-import-modal').style.display = 'none'; + }, 1500); + + } catch (err) { + ToastManager.show('Import failed: ' + err.message, 'error'); + } finally { + importBtn.disabled = false; + importBtn.textContent = 'Import'; + } + }, + + _showImportResult(result) { + const el = qs('#deck-import-result'); + el.style.display = 'block'; + + let html = '
'; + html += '' + result.added + ' cards imported'; + if (result.failed > 0) { + html += ' — ' + result.failed + ' not found'; + html += '
'; + html += 'Not found: ' + (result.failedNames || []).join(', '); + html += '
'; + } + html += '
'; + el.innerHTML = html; + }, + + /* ── Export ───────────────────────────────────────────── */ + + _exportDck() { + if (!this.deckId || this.deckCards.length === 0) { + ToastManager.show('No deck loaded.', 'warning'); + return; + } + const lines = []; + const commanders = this.deckCards.filter(c => c.is_commander); + const nonCmd = this.deckCards.filter(c => !c.is_commander); + + if (commanders.length > 0) { + lines.push('[Commander]'); + for (const c of commanders) { + lines.push(`1 ${c.card_name}`); + } + lines.push(''); + } + + lines.push('[Deck]'); + for (const c of nonCmd) { + lines.push(`${c.quantity || 1} ${c.card_name}`); + } + + const blob = new Blob([lines.join('\n')], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${(this.deckName || 'deck').replace(/[^a-z0-9_-]/gi, '_')}.dck`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + ToastManager.show('Exported .dck file', 'success'); + }, + + _exportToLanClient() { + if (!this.deckId || this.deckCards.length === 0) { + ToastManager.show('No deck loaded.', 'warning'); + return; + } + const commanders = this.deckCards.filter(c => c.is_commander); + const deck = this.deckCards.map(c => ({ + name: c.card_name, + quantity: c.quantity || 1, + is_commander: !!c.is_commander, + })); + window.parent.postMessage({ + type: 'DECK_BUILDER_EXPORT', + deck: deck, + deckName: this.deckName || 'Untitled', + commander: commanders.length > 0 ? commanders[0].card_name : '', + }, '*'); + ToastManager.show('Deck sent to game setup!', 'success'); + }, + + async _exportToSim() { + if (!this.deckId) { + ToastManager.show('Load a deck first.', 'warning'); + return; + } + const btn = qs('#deck-sim-btn'); + btn.disabled = true; + const originalText = btn.innerHTML; + btn.innerHTML = '
Simulating...'; + + try { + // Run a quick 10-game sim directly + const res = await fetch(LAB_API_BASE + '/api/sim/run-from-deck', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deckId: this.deckId, numGames: 10, recordLogs: false }), + }); + const ct = res.headers.get('content-type') || ''; + if (!ct.includes('application/json')) { + const txt = await res.text(); + throw new Error('Server returned non-JSON (HTTP ' + res.status + '): ' + txt.substring(0, 120)); + } + const startData = await res.json(); + if (startData.error) throw new Error(startData.error); + + // Poll until done + const simId = startData.simId; + let done = false; + while (!done) { + await new Promise(r => setTimeout(r, 300)); + const statusRes = await fetch(LAB_API_BASE + '/api/sim/status?simId=' + simId); + if (!statusRes.ok) throw new Error('Status poll failed: HTTP ' + statusRes.status); + const status = await statusRes.json(); + btn.innerHTML = '
' + status.completed + '/' + status.total; + if (status.status === 'complete') { + done = true; + const resultRes = await fetch(LAB_API_BASE + '/api/sim/result?simId=' + simId); + if (!resultRes.ok) throw new Error('Result fetch failed: HTTP ' + resultRes.status); + const result = await resultRes.json(); + const s = result.summary; + ToastManager.show( + `Win Rate: ${s.winRate}% (${s.wins}W/${s.losses}L) | Avg ${s.avgTurns} turns | ${s.elapsedSeconds}s`, + s.winRate >= 50 ? 'success' : 'warning', + 6000 + ); + // Offer to open full simulator + if (confirm(`Quick sim: ${s.winRate}% win rate (${s.wins}/${s.totalGames}).\n\nOpen the full Simulator page for detailed results?`)) { + window.location.href = 'simulator.html'; + } + } else if (status.status === 'error') { + throw new Error(status.error || 'Simulation failed'); + } + } + } catch (err) { + ToastManager.show(`Simulation failed: ${err.message}`, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = originalText; + } + }, + + /* ── AI Panels (Perplexity) ──────────────────────────── */ + + + /* ── Export Deck to Forge Sim Folder ────────────────── */ + + async _exportDckToSim() { + if (!this.deckId) { + ToastManager.show('Load a deck first.', 'warning'); + return; + } + const btn = qs('#deck-export-sim-btn'); + btn.disabled = true; + const originalText = btn.innerHTML; + btn.innerHTML = '
Exporting...'; + + try { + const res = await fetch(LAB_API_BASE + '/api/decks/' + this.deckId + '/export-to-sim', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const ct = res.headers.get('content-type') || ''; + if (!ct.includes('application/json')) { + const txt = await res.text(); + throw new Error('Server returned non-JSON (HTTP ' + res.status + '): ' + txt.substring(0, 120)); + } + const data = await res.json(); + if (!data.success) throw new Error(data.detail || 'Export failed'); + + ToastManager.show( + 'Exported "' + data.deckName + '" (' + data.totalCards + ' cards) to Forge sim folder', + 'success', + 5000 + ); + } catch (err) { + ToastManager.show('Export failed: ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = originalText; + } + }, + + async _checkPplxStatus() { + try { + const status = await apiGet('/api/pplx/status'); + this.pplxConfigured = status && status.configured; + } catch { + this.pplxConfigured = false; + } + const researchPanel = qs('#deck-ai-research-panel'); + const generatePanel = qs('#deck-ai-generate-panel'); + if (researchPanel) researchPanel.style.display = this.pplxConfigured ? '' : 'none'; + if (generatePanel) generatePanel.style.display = this.pplxConfigured ? '' : 'none'; + }, + + _bindAIPanels() { + // Research button + const researchBtn = qs('#ai-research-btn'); + if (researchBtn) { + researchBtn.addEventListener('click', () => this._runAIResearch()); + } + + // Generate button + const genBtn = qs('#ai-gen-btn'); + if (genBtn) { + genBtn.addEventListener('click', () => this._runAIGenerate()); + } + + // Commander autocomplete for AI Generate + this._bindAIGenCommanderSearch(); + }, + + /** Update the research button state — call after deck changes */ + _updateAIResearchBtn() { + const btn = qs('#ai-research-btn'); + if (!btn) return; + btn.disabled = !this.deckId || !this.pplxConfigured; + }, + + /* ── AI Research Flow ─────────────────────────────────── */ + + async _runAIResearch() { + if (!this.deckId) { + ToastManager.show('Load a deck first.', 'warning'); + return; + } + const btn = qs('#ai-research-btn'); + const loading = qs('#ai-research-loading'); + const results = qs('#ai-research-results'); + const controls = qs('#deck-ai-research-body .deck-ai-controls'); + + const goal = qs('#ai-research-goal').value.trim() || undefined; + const budgetRaw = qs('#ai-research-budget').value; + const budget = budgetRaw ? parseFloat(budgetRaw) : undefined; + const omitRaw = qs('#ai-research-omit').value.trim(); + const omitCards = omitRaw ? omitRaw.split(',').map(s => s.trim()).filter(Boolean) : []; + const useCollection = qs('#ai-research-use-collection').checked; + + btn.disabled = true; + loading.style.display = 'flex'; + results.style.display = 'none'; + results.innerHTML = ''; + + try { + const data = await apiPost('/api/deck-research', { + deck_id: this.deckId, + goal, + budget_usd: budget, + omit_cards: omitCards, + use_collection: useCollection, + }); + + if (data.error) { + results.innerHTML = '
AI returned an error: ' + this._esc(data.error) + '
'; + results.style.display = 'block'; + return; + } + + this._renderResearchResults(data, results); + results.style.display = 'block'; + + // Expand the panel body max-height so results are visible + const body = qs('#deck-ai-research-body'); + if (body) body.style.maxHeight = body.scrollHeight + 2000 + 'px'; + } catch (err) { + results.innerHTML = '
Research failed: ' + this._esc(err.message) + '
'; + results.style.display = 'block'; + ToastManager.show('AI research failed: ' + err.message, 'error'); + } finally { + btn.disabled = false; + loading.style.display = 'none'; + } + }, + + _renderResearchResults(data, container) { + let html = ''; + const esc = (t) => this._esc(t); + + // ── Header: Rating + Bracket + Archetype ────────────────── + const ratingNum = parseInt(data.overall_rating, 10); + const ratingClass = isNaN(ratingNum) ? '' : (ratingNum >= 7 ? 'high' : ratingNum >= 4 ? 'mid' : 'low'); + const bracketLvl = data.bracket_level && data.bracket_level.level; + const bracketCls = bracketLvl <= 1 ? 'bracket-1' : bracketLvl <= 2 ? 'bracket-2' : bracketLvl <= 3 ? 'bracket-3' : 'bracket-4'; + + html += '
'; + html += '
' + (isNaN(ratingNum) ? '?' : ratingNum) + '/10
'; + if (bracketLvl) html += 'Bracket ' + bracketLvl + ''; + if (data.archetype) html += '' + esc(data.archetype.replace('_', ' ')) + ''; + html += '
'; + + // Rating explanation + if (data.rating_explanation) { + html += '
' + esc(data.rating_explanation) + '
'; + } + + // ── Deck Description ────────────────────────────────────── + if (data.deck_description) { + html += '
'; + html += '
Deck Overview
'; + html += '
' + esc(data.deck_description) + '
'; + html += '
'; + } + + // ── Bracket Details ─────────────────────────────────────── + if (data.bracket_level) { + const b = data.bracket_level; + html += '
'; + html += '
Bracket Assessment
'; + html += '
'; + if (b.reasoning) html += '
Why Bracket ' + (b.level || '?') + ': ' + esc(b.reasoning) + '
'; + if (b.power_ceiling) html += '
Power Ceiling: ' + esc(b.power_ceiling) + '
'; + html += '
'; + } + + // ── Win Conditions ──────────────────────────────────────── + if (data.win_conditions && data.win_conditions.length) { + html += '
'; + html += '
Win Conditions
'; + for (const wc of data.win_conditions) { + const relCls = wc.reliability === 'high' ? 'high' : wc.reliability === 'medium' ? 'mid' : 'low'; + html += '
'; + html += '
'; + html += '' + esc(wc.name) + ''; + if (wc.reliability) html += '' + esc(wc.reliability) + ''; + html += '
'; + if (wc.cards_involved && wc.cards_involved.length) { + html += '
'; + for (const cn of wc.cards_involved) html += '' + esc(cn) + ''; + html += '
'; + } + if (wc.description) html += '
' + esc(wc.description) + '
'; + html += '
'; + } + html += '
'; + } + + // ── Synergy Packages ────────────────────────────────────── + if (data.synergy_packages && data.synergy_packages.length) { + html += '
'; + html += '
Synergy Packages
'; + for (const sp of data.synergy_packages) { + const strCls = sp.strength === 'strong' ? 'high' : sp.strength === 'moderate' ? 'mid' : 'low'; + html += '
'; + html += '
'; + html += '' + esc(sp.package_name) + ''; + if (sp.strength) html += '' + esc(sp.strength) + ''; + html += '
'; + if (sp.cards && sp.cards.length) { + html += '
'; + for (const cn of sp.cards) html += '' + esc(cn) + ''; + html += '
'; + } + if (sp.description) html += '
' + esc(sp.description) + '
'; + html += '
'; + } + html += '
'; + } + + // ── Threat Assessment (game phases) ─────────────────────── + if (data.threat_assessment) { + const ta = data.threat_assessment; + html += '
'; + html += '
Threat Assessment
'; + html += '
'; + if (ta.early_game) html += '
Early (T1-3)' + esc(ta.early_game) + '
'; + if (ta.mid_game) html += '
Mid (T4-7)' + esc(ta.mid_game) + '
'; + if (ta.late_game) html += '
Late (T8+)' + esc(ta.late_game) + '
'; + if (ta.vulnerability) html += '
Vulnerable To' + esc(ta.vulnerability) + '
'; + html += '
'; + } + + // ── Strengths / Weaknesses ──────────────────────────────── + if ((data.strengths && data.strengths.length) || (data.weaknesses && data.weaknesses.length)) { + html += '
'; + if (data.strengths && data.strengths.length) { + html += '
'; + html += '
Strengths
'; + html += '
    '; + for (const s of data.strengths) html += '
  • ' + esc(s) + '
  • '; + html += '
'; + } + if (data.weaknesses && data.weaknesses.length) { + html += '
'; + html += '
Weaknesses
'; + html += '
    '; + for (const w of data.weaknesses) html += '
  • ' + esc(w) + '
  • '; + html += '
'; + } + html += '
'; + } + + // ── Role Gaps ──────────────────────────────────────────── + if (data.role_gaps && typeof data.role_gaps === 'object') { + html += '
'; + html += '
Role Analysis
'; + html += '
'; + for (const [role, info] of Object.entries(data.role_gaps)) { + if (!info || typeof info !== 'object') continue; + const cur = info.current || 0; + const rec = info.recommended || 0; + const diff = cur - rec; + const barPct = rec > 0 ? Math.min(100, Math.round((cur / rec) * 100)) : 100; + const barCls = diff >= 0 ? 'good' : (diff >= -2 ? 'warn' : 'bad'); + html += '
'; + html += '' + esc(role.replace('_', ' ')) + ''; + html += '
'; + html += '' + cur + '/' + rec + ''; + html += '
'; + if (info.note) html += '
' + esc(info.note) + '
'; + } + html += '
'; + } + + // ── Mana Analysis ──────────────────────────────────────── + if (data.mana_analysis && typeof data.mana_analysis === 'object') { + const ma = data.mana_analysis; + html += '
'; + html += '
Mana Base Analysis
'; + if (ma.land_count) html += '
Lands: ' + esc(ma.land_count) + '
'; + if (ma.color_fixing) html += '
Color Fixing: ' + esc(ma.color_fixing) + '
'; + if (ma.ramp_package) html += '
Ramp: ' + esc(ma.ramp_package) + '
'; + if (ma.curve_assessment) html += '
Curve: ' + esc(ma.curve_assessment) + '
'; + if (ma.problem_cards && ma.problem_cards.length) { + html += '
Problem Cards: '; + html += ma.problem_cards.map(c => '' + esc(c) + '').join(' '); + html += '
'; + } + html += '
'; + } + + // ── Cuts ───────────────────────────────────────────────── + if (data.cuts && data.cuts.length) { + html += '
'; + html += '
Suggested Cuts (' + data.cuts.length + ')
'; + for (const c of data.cuts) { + const sevCls = c.severity === 'must_cut' ? 'sev-must' : c.severity === 'should_cut' ? 'sev-should' : 'sev-consider'; + html += '
'; + html += '' + esc(c.name) + ''; + if (c.severity) html += '' + esc(c.severity.replace('_', ' ')) + ''; + html += '
'; + if (c.reason) html += '
' + esc(c.reason) + '
'; + } + html += '
'; + } + + // ── Adds ───────────────────────────────────────────────── + if (data.adds && data.adds.length) { + html += '
'; + html += '
Suggested Adds (' + data.adds.length + ')
'; + for (const a of data.adds) { + const priCls = a.priority === 'critical' ? 'pri-critical' : a.priority === 'high' ? 'pri-high' : a.priority === 'medium' ? 'pri-medium' : 'pri-nice'; + html += '
'; + html += '' + esc(a.name) + ''; + if (a.role) html += '' + esc(a.role.replace('_', ' ')) + ''; + if (a.priority) html += '' + esc(a.priority.replace('_', ' ')) + ''; + if (a.from_collection) html += 'Owned'; + if (a.estimated_price_usd) html += '$' + a.estimated_price_usd.toFixed(2) + ''; + if (a.scryfall_id) html += ''; + html += '
'; + // Reason + synergy line + let detail = ''; + if (a.reason) detail += esc(a.reason); + if (a.synergy_with && a.synergy_with.length) { + detail += (detail ? ' ' : '') + 'Synergizes with: ' + a.synergy_with.map(c => esc(c)).join(', ') + ''; + } + if (detail) html += '
' + detail + '
'; + } + if (data.adds_total_usd) { + html += '
Total adds cost$' + data.adds_total_usd.toFixed(2) + '
'; + } + html += '
'; + } + + // ── Strategy Notes ──────────────────────────────────────── + if (data.strategy_notes) { + html += '
'; + html += '
Strategy Notes
'; + html += '
' + esc(data.strategy_notes) + '
'; + html += '
'; + } + + container.innerHTML = html; + + // Bind add buttons + qsa('.deck-ai-card-add-btn', container).forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const sfid = btn.dataset.sfid; + const name = btn.dataset.name; + if (sfid) this.addCardToDeck(sfid, name); + }); + }); + + // Attach card preview on hover for add rows with scryfall_id + qsa('.deck-ai-card-row[data-scryfall-id]', container).forEach(row => { + const sfid = row.dataset.scryfallId; + if (sfid) { + const nameEl = qs('.deck-ai-card-name', row); + if (nameEl) CardPreview.attach(nameEl, sfid); + } + }); + }, + + /* ── AI Generate Flow ─────────────────────────────────── */ + + _bindAIGenCommanderSearch() { + const input = qs('#ai-gen-commander'); + const dropdown = qs('#ai-gen-commander-dropdown'); + if (!input || !dropdown) return; + + const debouncedSearch = debounce(async (q) => { + if (!q || q.length < 2) { dropdown.style.display = 'none'; return; } + const cards = await scryfallSearchCards(q + ' is:commander'); + this._aiGenSuggestions = cards.slice(0, 8); + this._aiGenActiveIndex = -1; + this._renderAIGenDropdown(); + }, 350); + + input.addEventListener('input', (e) => { + // Clear selection if user edits after selecting + this.aiGenCommanderName = ''; + const sel = qs('#ai-gen-commander-selected'); + if (sel) sel.style.display = 'none'; + debouncedSearch(e.target.value.trim()); + }); + + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { + this._aiGenActiveIndex = Math.min(this._aiGenActiveIndex + 1, this._aiGenSuggestions.length - 1); + this._renderAIGenDropdown(); + e.preventDefault(); + } else if (e.key === 'ArrowUp') { + this._aiGenActiveIndex = Math.max(this._aiGenActiveIndex - 1, -1); + this._renderAIGenDropdown(); + e.preventDefault(); + } else if (e.key === 'Enter' && this._aiGenActiveIndex >= 0) { + e.preventDefault(); + const card = this._aiGenSuggestions[this._aiGenActiveIndex]; + if (card) this._selectAIGenCommander(card); + } else if (e.key === 'Escape') { + dropdown.style.display = 'none'; + } + }); + + dropdown.addEventListener('click', (e) => { + const item = e.target.closest('.deck-ai-commander-option'); + if (!item) return; + const idx = parseInt(item.dataset.index, 10); + const card = this._aiGenSuggestions[idx]; + if (card) this._selectAIGenCommander(card); + }); + + document.addEventListener('click', (e) => { + if (!input.contains(e.target) && !dropdown.contains(e.target)) { + dropdown.style.display = 'none'; + } + }); + }, + + _renderAIGenDropdown() { + const dropdown = qs('#ai-gen-commander-dropdown'); + dropdown.innerHTML = ''; + if (this._aiGenSuggestions.length === 0) { dropdown.style.display = 'none'; return; } + for (let i = 0; i < this._aiGenSuggestions.length; i++) { + const card = this._aiGenSuggestions[i]; + const item = el('div', 'deck-ai-commander-option' + (i === this._aiGenActiveIndex ? ' active' : '')); + item.dataset.index = i; + + const nameEl = el('span', 'deck-ai-commander-option-name', card.name); + const colorWrap = el('span', 'deck-commander-option-color'); + for (const c of (card.color_identity || [])) { + colorWrap.appendChild(buildColorChip(c)); + } + item.appendChild(nameEl); + item.appendChild(colorWrap); + dropdown.appendChild(item); + } + dropdown.style.display = 'block'; + }, + + _selectAIGenCommander(card) { + this.aiGenCommanderName = card.name; + const input = qs('#ai-gen-commander'); + const dropdown = qs('#ai-gen-commander-dropdown'); + input.value = card.name; + dropdown.style.display = 'none'; + }, + + async _runAIGenerate() { + const commander = this.aiGenCommanderName || qs('#ai-gen-commander').value.trim(); + if (!commander) { + ToastManager.show('Enter a commander name.', 'warning'); + qs('#ai-gen-commander').focus(); + return; + } + + const btn = qs('#ai-gen-btn'); + const loading = qs('#ai-gen-loading'); + const results = qs('#ai-gen-results'); + const controls = qs('#deck-ai-generate-body .deck-ai-controls'); + + const budgetRaw = qs('#ai-gen-budget').value; + const budget = budgetRaw ? parseFloat(budgetRaw) : undefined; + const omitRaw = qs('#ai-gen-omit').value.trim(); + const omitCards = omitRaw ? omitRaw.split(',').map(s => s.trim()).filter(Boolean) : []; + const useCollection = qs('#ai-gen-use-collection').checked; + + btn.disabled = true; + loading.style.display = 'flex'; + results.style.display = 'none'; + results.innerHTML = ''; + + try { + const data = await apiFetch('/api/deck-generate', { + method: 'POST', + body: JSON.stringify({ + commander, + budget_usd: budget, + omit_cards: omitCards, + use_collection: useCollection, + }), + }); + + if (data.error) { + results.innerHTML = '
AI returned an error: ' + this._esc(data.error) + '
'; + results.style.display = 'block'; + return; + } + + this._lastGenResult = data; + this._renderGenerateResults(data, results); + results.style.display = 'block'; + + // Expand panel + const body = qs('#deck-ai-generate-body'); + if (body) body.style.maxHeight = body.scrollHeight + 5000 + 'px'; + } catch (err) { + results.innerHTML = '
Generation failed: ' + this._esc(err.message) + '
'; + results.style.display = 'block'; + ToastManager.show('AI generation failed: ' + err.message, 'error'); + } finally { + btn.disabled = false; + loading.style.display = 'none'; + } + }, + + _renderGenerateResults(data, container) { + let html = ''; + + // Strategy + if (data.strategy) { + html += '
' + this._esc(data.strategy) + '
'; + } + + // Stats row + html += '
'; + html += '
' + (data.total_cards || '?') + 'Cards
'; + html += '
$' + (data.real_total_usd || 0).toFixed(0) + 'Total
'; + html += '
' + (data.from_collection_count || 0) + 'Owned
'; + html += '
'; + + // Cards grouped by role + if (data.cards && data.cards.length) { + const groups = {}; + for (const c of data.cards) { + const role = c.role || 'other'; + if (!groups[role]) groups[role] = []; + groups[role].push(c); + } + + const roleOrder = ['commander', 'creature', 'ramp', 'draw', 'removal', 'land', 'utility', 'win_condition', 'other']; + const sortedRoles = Object.keys(groups).sort((a, b) => { + const ai = roleOrder.indexOf(a); + const bi = roleOrder.indexOf(b); + return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); + }); + + for (const role of sortedRoles) { + const cards = groups[role]; + html += '
'; + html += '
' + this._esc(role.replace('_', ' ')) + ' (' + cards.length + ')
'; + for (const c of cards) { + html += '
'; + html += '' + this._esc(c.name) + ''; + if (c.from_collection) html += 'Owned'; + if (c.estimated_price_usd) html += '$' + c.estimated_price_usd.toFixed(2) + ''; + html += '
'; + } + html += '
'; + } + } + + // Reasoning + if (data.reasoning) { + const r = data.reasoning; + html += '
'; + html += '
AI Reasoning
'; + if (r.strategy) html += '
Strategy: ' + this._esc(r.strategy) + '
'; + if (r.mana_curve) html += '
Mana Curve: ' + this._esc(r.mana_curve) + '
'; + if (r.key_synergies) html += '
Key Synergies: ' + this._esc(r.key_synergies) + '
'; + if (r.budget_notes) html += '
Budget: ' + this._esc(r.budget_notes) + '
'; + if (r.collection_usage_notes) html += '
Collection Usage: ' + this._esc(r.collection_usage_notes) + '
'; + html += '
'; + } + + // Apply button + html += ''; + + container.innerHTML = html; + + // Bind apply button + const applyBtn = qs('#ai-gen-apply-btn', container); + if (applyBtn) { + applyBtn.addEventListener('click', () => this._applyGeneratedDeck()); + } + + // Attach card preview on hover + qsa('.deck-ai-card-row[data-scryfall-id]', container).forEach(row => { + const sfid = row.dataset.scryfallId; + if (sfid) { + const nameEl = qs('.deck-ai-card-name', row); + if (nameEl) CardPreview.attach(nameEl, sfid); + } + }); + }, + + async _applyGeneratedDeck() { + const data = this._lastGenResult; + if (!data || !data.cards || !data.cards.length) { + ToastManager.show('No generated deck to apply.', 'warning'); + return; + } + + const applyBtn = qs('#ai-gen-apply-btn'); + if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Creating deck...'; } + + try { + // Create a new deck + const deckName = (data.commander || 'AI Deck') + ' (AI)'; + const deck = await apiPost('/api/decks', { name: deckName }); + this.deckId = deck.id; + this.deckName = deck.name; + + // Add cards one at a time + let added = 0; + let failed = 0; + for (const card of data.cards) { + if (!card.scryfall_id && !card.name) continue; + + try { + let sfid = card.scryfall_id; + // If no scryfall_id, try fuzzy Scryfall lookup + if (!sfid) { + const scryCard = await scryfallFuzzySearch(card.name); + if (scryCard) sfid = scryCard.id; + } + if (!sfid) { failed++; continue; } + + const isCommander = (card.role === 'commander') ? 1 : 0; + await apiPost('/api/decks/' + deck.id + '/cards', { + scryfall_id: sfid, + card_name: card.name, + quantity: card.count || 1, + is_commander: isCommander, + }); + added++; + } catch (cardErr) { + console.warn('Failed to add ' + card.name + ':', cardErr.message); + failed++; + } + } + + // Reload + await this.loadDecks(); + await this.loadDeck(deck.id); + qs('#deck-selector').value = deck.id; + + const msg = 'Created "' + deckName + '" with ' + added + ' cards'; + if (failed) { + ToastManager.show(msg + ' (' + failed + ' failed to resolve)', 'warning', 5000); + } else { + ToastManager.show(msg, 'success', 4000); + } + } catch (err) { + ToastManager.show('Failed to create deck: ' + err.message, 'error'); + } finally { + if (applyBtn) { applyBtn.disabled = false; applyBtn.textContent = '\u2705 Create Deck from AI Results'; } + } + }, + + /** HTML-escape helper */ + _esc(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; + }, +}; + +/* ── Bootstrap ─────────────────────────────────────────────── */ + +document.addEventListener('DOMContentLoaded', () => { + DeckBuilder.init().catch(err => { + console.error('DeckBuilder init error:', err); + ToastManager.show('Failed to initialize deck builder', 'error'); + }); +}); diff --git a/lan-client/deckgenerator.css b/lan-client/deckgenerator.css new file mode 100644 index 0000000..67e884e --- /dev/null +++ b/lan-client/deckgenerator.css @@ -0,0 +1,1402 @@ +/* ═══════════════════════════════════════════════════════════ + Commander AI Lab — Auto Deck Generator Styles + Uses dg- prefix to avoid conflicts with lab- / deck- / coll- + Tokens from global.css design system + ═══════════════════════════════════════════════════════════ */ + +/* ── JS Backward-Compat Aliases ───────────────────────── + deckgenerator.js uses var(--lab-surface) in a template literal. + Map it to the global token so inline styles work. + ──────────────────────────────────────────────────────── */ +:root { + --lab-surface: var(--surface-1); +} + +/* ── Z-INDEX STACK ──────────────────────────────────────── + 10 card group headers (sticky) + 50 commander dropdown + 60 card preview + 500 toast container + ──────────────────────────────────────────────────────── */ + +/* ── 1. Page Base ───────────────────────────────────────── */ + +#deckgen-page { + min-height: calc(100vh - var(--nav-height)); + background: var(--bg); + color: var(--text); + display: flex; + flex-direction: column; + isolation: isolate; +} + +/* ── 2. Main Layout ─────────────────────────────────────── */ + +.dg-main { + display: grid; + grid-template-columns: 360px 1fr; + gap: 0; + flex: 1; + min-height: 0; +} + +.dg-col-config { + border-right: 1px solid var(--border); + padding: var(--sp-5); + overflow-y: auto; + overflow-x: visible; + height: calc(100vh - var(--nav-height)); + position: sticky; + top: var(--nav-height); + display: flex; + flex-direction: column; + gap: var(--sp-4); + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.dg-col-config::-webkit-scrollbar { width: 6px; } +.dg-col-config::-webkit-scrollbar-track { background: transparent; } +.dg-col-config::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +.dg-col-results { + padding: var(--sp-4) var(--sp-5); + overflow-y: auto; + min-height: calc(100vh - var(--nav-height)); + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.dg-col-results::-webkit-scrollbar { width: 6px; } +.dg-col-results::-webkit-scrollbar-track { background: transparent; } +.dg-col-results::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } + +/* ── 3. Panels ──────────────────────────────────────────── */ + +.dg-panel { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: visible; +} + +/* Commander panel needs visible overflow for dropdown */ +.dg-panel.dg-panel-commander { + overflow: visible; +} + +.dg-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--sp-3) var(--sp-4); + background: var(--accent-dim); + border-bottom: 1px solid var(--border); + border-radius: var(--radius-md) var(--radius-md) 0 0; +} + +.dg-panel-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +.dg-panel-hint { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.dg-panel-hint.dg-over { + color: var(--error); + font-weight: 600; +} + +.dg-panel-body { + padding: var(--sp-4); +} + +/* ── 4. Input ───────────────────────────────────────────── */ + +.dg-input { + width: 100%; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: var(--sp-2) var(--sp-3); + font-size: 13px; + font-family: inherit; + outline: none; + transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease); +} + +.dg-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.dg-input::placeholder { + color: var(--text-secondary); +} + +/* ── 5. Commander Search ────────────────────────────────── */ + +.dg-commander-search-wrap { + position: relative; +} + +.dg-commander-dropdown { + display: none; + position: fixed; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + max-height: 280px; + overflow-y: auto; + z-index: 9999; + box-shadow: var(--shadow-lg); +} + +.dg-commander-dropdown.open { + display: block; +} + +.dg-dropdown-item { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2) var(--sp-3); + cursor: pointer; + transition: background 0.1s; + border-bottom: 1px solid var(--border-subtle); +} + +.dg-dropdown-item:last-child { + border-bottom: none; +} + +.dg-dropdown-item:hover { + background: var(--accent-dim); +} + +.dg-dropdown-item-img { + width: 32px; + height: 44px; + object-fit: cover; + border-radius: 3px; + flex-shrink: 0; + background: var(--surface-1); +} + +.dg-dropdown-item-info { + flex: 1; + min-width: 0; +} + +.dg-dropdown-item-name { + font-size: 13px; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dg-dropdown-item-type { + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dg-dropdown-item-badge { + font-size: 10px; + padding: 1px 6px; + border-radius: var(--radius-md); + font-weight: 600; + flex-shrink: 0; +} + +.dg-badge-owned { + background: var(--success-dim); + color: var(--success); + border: 1px solid rgba(74, 222, 128, 0.3); +} + +.dg-badge-scryfall { + background: var(--accent-dim); + color: var(--accent); + border: 1px solid var(--accent-glow); +} + +.dg-dropdown-empty { + text-align: center; + padding: var(--sp-4); + color: var(--text-secondary); + font-size: 12px; +} + +.dg-dropdown-loading { + text-align: center; + padding: var(--sp-4); + color: var(--accent); + font-size: 12px; +} + +/* Commander Selected Card */ + +.dg-commander-selected { + margin-top: var(--sp-3); +} + +.dg-commander-card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-3); + background: var(--bg); + border: 1px solid var(--accent); + border-radius: var(--radius); + position: relative; +} + +.dg-commander-img { + width: 100%; + max-width: 260px; + height: auto; + border-radius: 12px; + flex-shrink: 0; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +.dg-commander-info { + text-align: center; + min-width: 0; + width: 100%; +} + +.dg-commander-name { + font-size: 15px; + font-weight: 600; + color: #fff; +} + +.dg-commander-type { + font-size: 11px; + color: var(--text-secondary); + margin-top: 2px; +} + +.dg-commander-colors { + display: flex; + justify-content: center; + gap: 3px; + margin-top: var(--sp-1); +} + +/* MTG mana color pips — domain-specific colors */ +.dg-color-pip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + font-size: 10px; + font-weight: 700; + box-shadow: var(--shadow-sm); +} + +.dg-pip-W { background: #f9faf4; color: #3d3929; } +.dg-pip-U { background: #0e67ab; color: #fff; } +.dg-pip-B { background: #2b2424; color: #ccc; } +.dg-pip-R { background: #d3202a; color: #fff; } +.dg-pip-G { background: #00733e; color: #fff; } +.dg-pip-C { background: #706f6f; color: #fff; } + +.dg-commander-clear { + position: absolute; + top: var(--sp-2); + right: var(--sp-2); + background: rgba(0, 0, 0, 0.6); + border: none; + color: #fff; + font-size: 18px; + cursor: pointer; + padding: 2px 8px; + line-height: 1; + border-radius: 50%; + z-index: 2; + transition: background var(--duration) var(--ease), color var(--duration) var(--ease); +} + +.dg-commander-clear:hover { + color: var(--error); +} + +/* ── 6. Source Toggles ──────────────────────────────────── */ + +.dg-source-toggles { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.dg-source-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-2) 0; +} + +.dg-source-label { + font-size: 13px; + color: var(--text); + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.dg-source-exp { + font-size: 9px; + padding: 1px 5px; + border-radius: var(--radius-sm); + background: var(--warning-dim); + color: var(--warning); + border: 1px solid rgba(251, 191, 36, 0.3); + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.3px; +} + +/* ── 7. Toggle Switch ───────────────────────────────────── */ + +.dg-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-2) 0; + cursor: pointer; + user-select: none; +} + +.dg-toggle-label { + font-size: 13px; + color: var(--text); +} + +.dg-toggle-check { + display: none; +} + +.dg-toggle-track { + width: 34px; + height: 18px; + background: var(--border); + border-radius: 9px; + position: relative; + flex-shrink: 0; + transition: background 0.2s; +} + +.dg-toggle-check:checked + .dg-toggle-track { + background: var(--accent); +} + +.dg-toggle-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + transition: transform 0.2s; +} + +.dg-toggle-check:checked + .dg-toggle-track .dg-toggle-thumb { + transform: translateX(16px); +} + +/* ── 8. Ratio Controls ──────────────────────────────────── */ + +.dg-ratio-list { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.dg-ratio-row { + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.dg-ratio-label { + font-size: 12px; + color: var(--text-secondary); + width: 80px; + flex-shrink: 0; + line-height: 1.3; +} + +.dg-ratio-slider { + flex: 1; + min-width: 0; + -webkit-appearance: none; + appearance: none; + height: 6px; + background: var(--border); + border-radius: 3px; + outline: none; + margin: 0 4px; +} + +.dg-ratio-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: 2px solid var(--bg); + box-shadow: 0 0 6px var(--accent-glow); + margin-top: -5px; +} + +.dg-ratio-slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: 2px solid var(--bg); +} + +.dg-ratio-value { + width: 52px; + min-width: 52px; + text-align: center; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 4px 6px; + font-size: 13px; + font-variant-numeric: tabular-nums; + flex-shrink: 0; + -moz-appearance: textfield; +} + +.dg-ratio-value::-webkit-outer-spin-button, +.dg-ratio-value::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.dg-ratio-value:focus { + outline: none; + border-color: var(--accent); +} + +/* ── 9. Buttons ─────────────────────────────────────────── */ + +.dg-actions { + padding: var(--sp-1) 0 var(--sp-2); +} + +.dg-btn { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-3) var(--sp-5); + border-radius: var(--radius-md); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all var(--duration) var(--ease); + border: none; + font-family: inherit; +} + +.dg-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.dg-btn-icon { + font-size: 14px; +} + +.dg-btn-preview { + background: var(--success); + color: var(--text-inverse); + width: 100%; + justify-content: center; +} + +.dg-btn-preview:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 16px var(--success-dim); +} + +.dg-btn-save { + background: var(--accent); + color: var(--text-inverse); +} + +.dg-btn-save:hover:not(:disabled) { + background: var(--accent-hover); + box-shadow: 0 4px 12px var(--accent-glow); +} + +.dg-btn-ghost { + background: var(--surface-1); + color: var(--text); + border: 1px solid var(--border); +} + +.dg-btn-ghost:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +/* ── 10. Empty State ────────────────────────────────────── */ + +.dg-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + min-height: 400px; + text-align: center; + gap: var(--sp-3); +} + +.dg-empty-icon { + font-size: 48px; + opacity: 0.25; + user-select: none; +} + +.dg-empty-title { + font-size: 18px; + font-weight: 600; + color: var(--text-secondary); +} + +.dg-empty-text { + font-size: 13px; + color: var(--text-secondary); + max-width: 300px; + line-height: 1.5; +} + +/* ── 11. Loading State ──────────────────────────────────── */ + +.dg-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + gap: var(--sp-4); +} + +.dg-spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: dg-spin 0.7s linear infinite; +} + +@keyframes dg-spin { + to { transform: rotate(360deg); } +} + +.dg-loading-text { + font-size: 14px; + color: var(--text-secondary); +} + +/* ── 12. Results — Stats Bar ────────────────────────────── */ + +.dg-stats-bar { + display: flex; + gap: var(--sp-3); + flex-wrap: wrap; + margin-bottom: var(--sp-4); +} + +.dg-stat-chip { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + font-size: 12px; +} + +.dg-stat-num { + font-weight: 700; + font-variant-numeric: tabular-nums; + color: #fff; +} + +.dg-stat-label { + color: var(--text-secondary); +} + +.dg-stat-owned .dg-stat-num { color: var(--success); } +.dg-stat-proxy .dg-stat-num { color: var(--warning); } + +/* ── 13. Results — Card Groups ──────────────────────────── */ + +.dg-card-groups { + display: flex; + flex-direction: column; + gap: var(--sp-3); +} + +.dg-card-group { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; +} + +.dg-group-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--sp-2) var(--sp-3); + background: var(--accent-dim); + border-bottom: 1px solid var(--border); + cursor: pointer; + user-select: none; + transition: background var(--duration) var(--ease); +} + +.dg-group-header:hover { + background: rgba(91, 158, 240, 0.08); +} + +.dg-group-title { + font-size: 13px; + font-weight: 600; + color: #fff; +} + +.dg-group-count { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.dg-group-body { + display: flex; + flex-direction: column; +} + +.dg-group-body.collapsed { + display: none; +} + +.dg-card-row { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-2) var(--sp-3); + border-bottom: 1px solid var(--border-subtle); + transition: background 0.1s; + position: relative; +} + +.dg-card-row:last-child { + border-bottom: none; +} + +.dg-card-row:hover { + background: var(--accent-dim); +} + +.dg-card-row-mana { + font-size: 12px; + color: var(--text-secondary); + width: 50px; + text-align: right; + flex-shrink: 0; + font-family: var(--font-mono); +} + +.dg-card-row-name { + flex: 1; + font-size: 13px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dg-card-row-roles { + display: flex; + gap: 3px; + flex-shrink: 0; +} + +.dg-role-chip { + font-size: 9px; + padding: 1px 5px; + border-radius: var(--radius-sm); + background: var(--accent-dim); + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.3px; + font-weight: 600; +} + +.dg-card-row-source { + font-size: 10px; + padding: 1px 6px; + border-radius: var(--radius-md); + font-weight: 600; + flex-shrink: 0; +} + +.dg-src-collection { + background: var(--success-dim); + color: var(--success); +} + +.dg-src-edhrec { + background: var(--purple-dim); + color: var(--purple); +} + +.dg-src-archidekt { + background: var(--accent-dim); + color: var(--accent); +} + +.dg-src-moxfield { + background: var(--warning-dim); + color: var(--warning); +} + +.dg-src-mtggoldfish { + background: var(--error-dim); + color: var(--error); +} + +.dg-src-template { + background: rgba(139, 148, 158, 0.12); + color: var(--text-secondary); +} + +.dg-card-row-owned { + font-size: 10px; + color: var(--text-secondary); + flex-shrink: 0; + width: 32px; + text-align: center; +} + +.dg-card-row-owned.is-owned { + color: var(--success); + font-weight: 600; +} + +.dg-card-row-owned.is-proxy { + color: var(--warning); +} + +/* Card Image Preview on Hover */ + +.dg-card-preview { + display: none; + position: fixed; + z-index: 60; + pointer-events: none; +} + +.dg-card-preview.visible { + display: block; +} + +.dg-card-preview img { + width: 240px; + border-radius: var(--radius-md); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6); +} + +/* ── 14. Save Bar ───────────────────────────────────────── */ + +.dg-save-bar { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-4) 0 var(--sp-2); + position: sticky; + bottom: 0; + background: linear-gradient(transparent, var(--bg) 20%); + padding-top: var(--sp-6); +} + +/* ── 15. Toast ──────────────────────────────────────────── */ + +.dg-toast-container { + position: fixed; + bottom: var(--sp-5); + right: var(--sp-5); + z-index: 500; + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.dg-toast { + padding: var(--sp-3) var(--sp-4); + border-radius: var(--radius-md); + font-size: 13px; + color: #fff; + animation: dg-toast-in 0.25s ease, dg-toast-out 0.3s ease 2.5s forwards; + max-width: 360px; + box-shadow: var(--shadow-md); +} + +.dg-toast-success { + background: var(--success); +} + +.dg-toast-error { + background: var(--error); +} + +.dg-toast-info { + background: var(--accent); + color: var(--text-inverse); +} + +@keyframes dg-toast-in { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes dg-toast-out { + from { opacity: 1; } + to { opacity: 0; transform: translateY(-8px); } +} + +/* ── 16. Responsive ─────────────────────────────────────── */ + +@media (max-width: 900px) { + .dg-main { + grid-template-columns: 1fr; + } + + .dg-col-config { + height: auto; + position: static; + border-right: none; + border-bottom: 1px solid var(--border); + } + + .dg-col-results { + max-height: none; + } +} + +@media (max-width: 768px) { + .dg-col-config { + padding: var(--sp-3); + } + + .dg-col-results { + padding: var(--sp-3); + } + + .dg-card-preview { + display: none !important; + } +} + +/* ══════════════════════════════════════════════════════════════ + V3 Additions — Bracket, Strategy, Substitution UI + ══════════════════════════════════════════════════════════════ */ + +/* ── Bracket Buttons ──────────────────────────────────────── */ +.dg-bracket-row { + margin-top: var(--sp-3); +} + +.dg-bracket-btns { + display: flex; + gap: var(--sp-2); + margin-top: var(--sp-2); +} + +.dg-bracket-btn { + flex: 1; + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-1); + color: var(--text-muted); + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.15s; +} + +.dg-bracket-btn:hover { + border-color: var(--accent); + color: var(--text); +} + +.dg-bracket-btn.dg-bracket-active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +/* ── Budget Row ───────────────────────────────────────────── */ +.dg-budget-row { + display: flex; + gap: var(--sp-2); +} + +.dg-input-narrow { + width: 120px; + flex-shrink: 0; +} + +.dg-select { + flex: 1; + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-1); + color: var(--text); + font-size: 0.85rem; +} + +/* ── Model Row ────────────────────────────────────────────── */ +.dg-model-row { + margin-top: var(--sp-3); + display: flex; + align-items: center; + gap: var(--sp-3); +} + +.dg-model-row .dg-select { + flex: 1; +} + +.dg-label { + font-size: 0.8rem; + color: var(--text-muted); + font-weight: 500; + white-space: nowrap; +} + +/* ── Strategy Bar ─────────────────────────────────────────── */ +.dg-strategy-bar { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--sp-3) var(--sp-4); + margin-bottom: var(--sp-3); +} + +.dg-strategy-info { + display: flex; + align-items: center; + gap: var(--sp-2); + flex-wrap: wrap; +} + +.dg-bracket-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 24px; + border-radius: var(--radius-sm); + font-weight: 700; + font-size: 0.8rem; + color: #fff; + padding: 0 var(--sp-2); +} + +.dg-bracket-1 { background: #4ade80; color: #0a0a0a; } +.dg-bracket-2 { background: #60a5fa; } +.dg-bracket-3 { background: #f59e0b; color: #0a0a0a; } +.dg-bracket-4 { background: #ef4444; } + +.dg-archetype-badge { + background: var(--surface-2, #2a2a2a); + color: var(--text-muted); + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.dg-strategy-text { + font-size: 0.85rem; + color: var(--text); +} + +.dg-bracket-reasoning { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: var(--sp-2); + line-height: 1.4; +} + +.dg-game-changers { + margin-top: var(--sp-2); + font-size: 0.75rem; + color: var(--text-muted); +} + +.dg-gc-chip { + display: inline-block; + background: rgba(239, 68, 68, 0.15); + color: #fca5a5; + padding: 1px 6px; + border-radius: var(--radius-sm); + font-size: 0.7rem; + margin-left: var(--sp-1); +} + +/* ── Substitution Summary ─────────────────────────────────── */ +.dg-sub-summary { + margin-bottom: var(--sp-3); +} + +.dg-sub-bar { + display: flex; + gap: var(--sp-3); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--sp-2) var(--sp-4); +} + +.dg-sub-stat { + font-size: 0.8rem; + font-weight: 600; +} + +.dg-sub-owned { color: #4ade80; } +.dg-sub-substituted { color: #60a5fa; } +.dg-sub-missing { color: #f59e0b; } + +/* ── Card Status Badges ───────────────────────────────────── */ +.dg-card-status { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: var(--radius-sm); + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.dg-badge-owned { + background: rgba(74, 222, 128, 0.15); + color: #4ade80; +} + +.dg-badge-substituted { + background: rgba(96, 165, 250, 0.15); + color: #60a5fa; +} + +.dg-badge-missing { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +/* ── Card Row V3 ──────────────────────────────────────────── */ +.dg-card-row { + padding: var(--sp-2) var(--sp-3); + border-bottom: 1px solid rgba(255,255,255,0.04); +} + +.dg-card-row-main { + display: flex; + align-items: center; + gap: var(--sp-2); +} + +.dg-card-row-name { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.85rem; +} + +.dg-card-count { + display: inline-block; + background: var(--accent, #6c63ff); + color: #fff; + border-radius: 4px; + padding: 1px 5px; + font-size: 0.7rem; + font-weight: 700; + margin-right: 2px; + min-width: 20px; + text-align: center; +} + +.dg-card-row-roles { + display: flex; + gap: 3px; + flex-shrink: 0; +} + +.dg-card-price { + font-size: 0.7rem; + color: var(--text-muted); + flex-shrink: 0; +} + +.dg-card-reason { + font-size: 0.7rem; + color: var(--text-muted); + padding: 2px 0 0 var(--sp-3); + line-height: 1.3; +} + +/* ── Substitution Lines ───────────────────────────────────── */ +.dg-card-sub-line { + padding: 2px 0 0 var(--sp-3); + font-size: 0.75rem; +} + +.dg-sub-arrow { + color: #60a5fa; + font-weight: 700; +} + +.dg-sub-name { + color: #60a5fa; + font-weight: 600; +} + +.dg-sub-label { + color: var(--text-muted); +} + +.dg-alt-chip { + display: inline-block; + background: rgba(255,255,255,0.05); + padding: 1px 5px; + border-radius: var(--radius-sm); + font-size: 0.65rem; + margin-left: 3px; + color: var(--text-muted); + cursor: help; +} + +/* ── Card row status striping ─────────────────────────────── */ +.dg-card-status-owned {} +.dg-card-status-substituted { + background: rgba(96, 165, 250, 0.04); + border-left: 2px solid rgba(96, 165, 250, 0.3); +} +.dg-card-status-missing { + background: rgba(245, 158, 11, 0.04); + border-left: 2px solid rgba(245, 158, 11, 0.3); +} + +/* ── Commander row highlight ──────────────────────────────── */ +.dg-card-row-commander { + background: rgba(139, 92, 246, 0.08); + border-left: 2px solid rgba(139, 92, 246, 0.5); +} + +/* ── Export Buttons ────────────────────────────────────────── */ +.dg-export-group { + display: flex; + gap: var(--sp-2); + margin-left: auto; +} + +.dg-btn-export { + padding: var(--sp-2) var(--sp-3) !important; + font-size: 0.75rem !important; + background: var(--surface-1) !important; + border: 1px solid var(--border) !important; + color: var(--text-muted) !important; +} + +.dg-btn-export:hover { + border-color: var(--accent) !important; + color: var(--text) !important; +} + +/* ── Stats additions ──────────────────────────────────────── */ +.dg-stat-substituted .dg-stat-num { color: #60a5fa; } +.dg-stat-missing .dg-stat-num { color: #f59e0b; } +.dg-stat-price .dg-stat-num { color: #a78bfa; } + + +/* -- Conversation Log ---------------------------------------- */ +.dg-conversation-log { + margin-top: var(--sp-3); + max-height: 200px; + overflow-y: auto; + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: var(--sp-3); + font-family: 'Courier New', monospace; + font-size: 0.8rem; + scrollbar-width: thin; +} + +.dg-log-entry { + padding: 3px 0; + line-height: 1.5; + border-bottom: 1px solid rgba(255,255,255,0.05); +} + +.dg-log-entry:last-child { + border-bottom: none; +} + +.dg-log-ts { + color: var(--text-muted); + margin-right: 6px; +} + +.dg-log-info { + color: var(--text); +} + +.dg-log-success { + color: #60a5fa; +} + +.dg-log-error { + color: #f59e0b; +} + +/* ── Progress Bar (Deck Generation) ──────────────────── */ +.dg-progress-wrap { + width: 100%; + max-width: 400px; + margin-top: 16px; +} + +.dg-progress-bar { + height: 6px; + background: var(--surface-2, #2a2a3a); + border-radius: 3px; + overflow: hidden; +} + +.dg-progress-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--accent, #7c6ff7), #a78bfa); + border-radius: 3px; + transition: width 1s ease; +} + +.dg-progress-meta { + display: flex; + justify-content: space-between; + margin-top: 6px; + font-size: 0.78rem; +} + +.dg-progress-phase { + color: var(--text-muted, #999); +} + +.dg-progress-elapsed { + color: var(--text-muted, #999); + font-variant-numeric: tabular-nums; +} + +/* — Omit Cards Chips ————————————————————————— */ +.dg-omit-wrap { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 6px 8px; + border: 1px solid var(--border, #333); + border-radius: 6px; + background: var(--bg-input, #1a1a2e); + min-height: 38px; + cursor: text; +} + +.dg-omit-wrap:focus-within { + border-color: var(--accent, #6c5ce7); + box-shadow: 0 0 0 2px rgba(108, 92, 231, 0.25); +} + +.dg-omit-chips { + display: contents; +} + +.dg-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 12px; + background: var(--accent, #6c5ce7); + color: #fff; + font-size: 0.8rem; + white-space: nowrap; + line-height: 1.6; +} + +.dg-chip-x { + cursor: pointer; + font-size: 0.9rem; + opacity: 0.7; + margin-left: 2px; +} + +.dg-chip-x:hover { + opacity: 1; +} + +.dg-omit-input { + flex: 1 1 120px; + border: none !important; + background: transparent !important; + padding: 2px 4px !important; + min-width: 120px; + outline: none; + box-shadow: none !important; +} + +.dg-hint { + display: block; + margin-top: 6px; + color: var(--text-muted, #999); + font-size: 0.75rem; +} diff --git a/lan-client/deckgenerator.html b/lan-client/deckgenerator.html new file mode 100644 index 0000000..ee89c05 --- /dev/null +++ b/lan-client/deckgenerator.html @@ -0,0 +1,231 @@ + + + + + + Commander AI Lab — Auto Deck Generator V3 + + + + + + + + + + +
+ + +
+ + +
+ + +
+
+ Commander +
+
+
+ +
+
+ +
+
+ + +
+
+ Strategy & Power +
+
+ +
+ +
+ + + + +
+
+
+
+ + +
+
+ Deck Name +
+
+ +
+
+ + +
+
+ Budget +
+
+
+ + +
+
+
+ + +
+
+ Options +
+
+ + +
+ + +
+
+
+ + +
+
+ Omit Cards +
+
+
+
+ +
+ Cards listed here will be excluded from the generated deck. +
+
+ + +
+ +
+ + +
+
[Ready] Waiting for deck generation...
+
+ +
+ + +
+ +
+
⚡
+
V3 AI Deck Generator
+
Select a commander, set your bracket and strategy, then click "Generate Deck" to build a complete 100-card deck using Claude Opus with structured output.
+
+ + + + + + + +
+ +
+ + +
+ +
+ + + + + diff --git a/lan-client/deckgenerator.js b/lan-client/deckgenerator.js new file mode 100644 index 0000000..711cdf2 --- /dev/null +++ b/lan-client/deckgenerator.js @@ -0,0 +1,792 @@ +/** + * Commander AI Lab — Auto Deck Generator V3 UI + * ═══════════════════════════════════════════════ + * + * V3 Endpoints: + * GET /api/deck/v3/status — check V3 generator status + * GET /api/deck-generator/commander-search — autocomplete commanders + * POST /api/deck/v3/generate — generate deck (local AI structured output) + * POST /api/deck/v3/commit — generate + save to Deck Builder + * POST /api/deck/v3/export/csv — export as CSV + * POST /api/deck/v3/export/dck — export as Forge .dck + * POST /api/deck/v3/export/moxfield — export in Moxfield paste format + * POST /api/deck/v3/export/shopping — shopping list of missing cards + */ + +const DeckGenerator = (() => { + 'use strict'; + + const API_BASE = window.LAB_API_BASE || window.location.origin; + const DEBOUNCE_MS = 350; + + const COLOR_MAP = { + W: { label: 'W', cls: 'dg-pip-W' }, + U: { label: 'U', cls: 'dg-pip-U' }, + B: { label: 'B', cls: 'dg-pip-B' }, + R: { label: 'R', cls: 'dg-pip-R' }, + G: { label: 'G', cls: 'dg-pip-G' }, + C: { label: 'C', cls: 'dg-pip-C' }, + }; + + const TYPE_ORDER = ['Creature', 'Instant', 'Sorcery', 'Artifact', 'Enchantment', 'Planeswalker', 'Land', 'Battle', 'Other']; + const TYPE_ICONS = { + 'Creature': '🐉', 'Instant': '⚡', 'Sorcery': '🌀', 'Artifact': '🔧', + 'Enchantment': '✨', 'Planeswalker': '🌟', 'Land': '🏔', 'Battle': '⚔', + 'Other': '🃏', + }; + + const STATUS_BADGES = { + owned: { label: 'Owned', cls: 'dg-badge-owned' }, + substituted: { label: 'Substituted', cls: 'dg-badge-substituted' }, + missing: { label: 'Missing', cls: 'dg-badge-missing' }, + }; + + // ── State ────────────────────────────────────────────── + let state = { + commander: null, + targetBracket: 3, + previewResult: null, + lastRequestBody: null, + isLoading: false, + v3Ready: false, + }; + + let searchTimeout = null; + + // ── Progress bar state ────────────────────────────── + const PROGRESS_PHASES = [ + { at: 0, pct: 5, msg: 'Connecting to AI model...' }, + { at: 3, pct: 10, msg: 'Resolving commander on Scryfall...' }, + { at: 6, pct: 18, msg: 'Building collection summary...' }, + { at: 10, pct: 25, msg: 'Sending prompt to LLM...' }, + { at: 20, pct: 40, msg: 'AI is building the 99...' }, + { at: 35, pct: 55, msg: 'Selecting cards and filling slots...' }, + { at: 50, pct: 65, msg: 'Running smart substitution...' }, + { at: 70, pct: 78, msg: 'Cross-referencing with collection...' }, + { at: 90, pct: 88, msg: 'Almost there — finalizing deck...' }, + { at: 110, pct: 93, msg: 'Still working — large model can take a moment...' }, + ]; + let _progressInterval = null; + let _progressStart = 0; + + function startProgress() { + _progressStart = Date.now(); + const wrap = $('dg-progress-wrap'); + const fill = $('dg-progress-fill'); + const phase = $('dg-progress-phase'); + const elapsed = $('dg-progress-elapsed'); + if (!wrap) return; + wrap.style.display = 'block'; + fill.style.width = '0%'; + phase.textContent = ''; + elapsed.textContent = '0s elapsed'; + let lastPhaseIdx = -1; + _progressInterval = setInterval(() => { + const secs = Math.floor((Date.now() - _progressStart) / 1000); + elapsed.textContent = secs + 's elapsed'; + for (let i = PROGRESS_PHASES.length - 1; i >= 0; i--) { + if (secs >= PROGRESS_PHASES[i].at) { + fill.style.width = PROGRESS_PHASES[i].pct + '%'; + if (i !== lastPhaseIdx) { + phase.textContent = PROGRESS_PHASES[i].msg; + logMessage(PROGRESS_PHASES[i].msg, 'info'); + lastPhaseIdx = i; + } + break; + } + } + }, 1000); + } + + function stopProgress(success) { + if (_progressInterval) { + clearInterval(_progressInterval); + _progressInterval = null; + } + const fill = $('dg-progress-fill'); + const phase = $('dg-progress-phase'); + const elapsed = $('dg-progress-elapsed'); + if (fill) fill.style.width = success ? '100%' : '0%'; + if (phase) phase.textContent = success ? 'Done!' : 'Failed'; + const secs = Math.floor((Date.now() - _progressStart) / 1000); + if (elapsed) elapsed.textContent = secs + 's total'; + } + + // ── Omit Cards (chips) ───────────────────────────── + let omitCards = []; + + function addOmitCard(name) { + const trimmed = name.trim(); + if (!trimmed) return; + if (omitCards.some(c => c.toLowerCase() === trimmed.toLowerCase())) return; + omitCards.push(trimmed); + renderOmitChips(); + } + + function removeOmitCard(idx) { + omitCards.splice(idx, 1); + renderOmitChips(); + } + + function renderOmitChips() { + const container = $('dg-omit-chips'); + if (!container) return; + container.innerHTML = omitCards.map((name, i) => + '' + + escHtml(name) + + '×' + + '' + ).join(''); + container.querySelectorAll('.dg-chip-x').forEach(el => { + el.addEventListener('click', () => removeOmitCard(parseInt(el.dataset.idx, 10))); + }); + } + + const $ = (id) => document.getElementById(id); + + // ── Init ──────────────────────────────────────────────── + async function init() { + await checkV3Status(); + bindEvents(); + checkQueryParams(); + } + + async function checkV3Status() { + try { + const resp = await fetch(API_BASE + '/api/deck/v3/status'); + const data = await resp.json(); + state.v3Ready = data.initialized; + if (!state.v3Ready) { + // Warn but do not block — local/Ollama mode may still work + console.warn('V3 status: not initialized. Error:', data.error || 'unknown'); + toast('V3 generator not ready — check that the local AI model (gpt-oss:20b) is running via Ollama', 'warning'); + } + } catch (e) { + console.warn('V3 status check failed:', e); + } + } + + function bindEvents() { + // Commander search + const input = $('dg-commander-input'); + input.addEventListener('input', () => { + clearTimeout(searchTimeout); + const q = input.value.trim(); + if (q.length < 2) { hideDropdown(); return; } + searchTimeout = setTimeout(() => searchCommander(q), DEBOUNCE_MS); + }); + + input.addEventListener('focus', () => { + const q = input.value.trim(); + if (q.length >= 2) searchCommander(q); + }); + + document.addEventListener('click', (e) => { + const wrap = document.querySelector('.dg-commander-search-wrap'); + if (wrap && !wrap.contains(e.target)) hideDropdown(); + }); + + $('dg-commander-clear').addEventListener('click', clearCommander); + + // Bracket buttons + document.querySelectorAll('.dg-bracket-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.dg-bracket-btn').forEach(b => b.classList.remove('dg-bracket-active')); + btn.classList.add('dg-bracket-active'); + state.targetBracket = parseInt(btn.dataset.bracket, 10); + }); + }); + + // Generate & save + $('dg-preview-btn').addEventListener('click', generateDeck); + $('dg-save-btn').addEventListener('click', commitDeck); + $('dg-regenerate-btn').addEventListener('click', generateDeck); + + // Export buttons + $('dg-export-csv').addEventListener('click', () => exportDeck('csv')); + $('dg-export-dck').addEventListener('click', () => exportDeck('dck')); + $('dg-export-moxfield').addEventListener('click', () => exportMoxfield()); + $('dg-export-shopping').addEventListener('click', () => exportShopping()); + + // Omit cards chips input + const omitInput = $('dg-omit-input'); + if (omitInput) { + omitInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addOmitCard(omitInput.value); + omitInput.value = ''; + } + }); + } + const omitWrap = $('dg-omit-wrap'); + if (omitWrap) { + omitWrap.addEventListener('click', () => omitInput && omitInput.focus()); + } + } + + function checkQueryParams() { + const params = new URLSearchParams(window.location.search); + const name = params.get('commander'); + if (name) { + $('dg-commander-input').value = name; + searchCommander(name); + } + } + + // ── Build Request Body ────────────────────────────────── + function buildRequestBody() { + const budget = parseFloat($('dg-budget').value) || null; + return { + commander_name: state.commander.name, + strategy: $('dg-strategy').value.trim(), + target_bracket: state.targetBracket, + budget_usd: budget, + budget_mode: $('dg-budget-mode').value, + omit_cards: omitCards.slice(), + use_collection: $('dg-use-collection').checked, + run_substitution: $('dg-substitution').checked, + model: $('dg-model').value, + deck_name: $('dg-deck-name').value.trim() || '', + }; + } + + // ── Commander Search ──────────────────────────────────── + function positionDropdown() { + const input = $('dg-commander-input'); + const dropdown = $('dg-commander-dropdown'); + const rect = input.getBoundingClientRect(); + dropdown.style.top = (rect.bottom + 4) + 'px'; + dropdown.style.left = rect.left + 'px'; + dropdown.style.width = rect.width + 'px'; + } + + async function searchCommander(q) { + const dropdown = $('dg-commander-dropdown'); + dropdown.innerHTML = '
Searching...
'; + dropdown.classList.add('open'); + positionDropdown(); + + try { + const resp = await fetch(API_BASE + '/api/deck-generator/commander-search?q=' + encodeURIComponent(q)); + const data = await resp.json(); + renderDropdown(data.results || []); + } catch (e) { + dropdown.innerHTML = '
Search failed
'; + } + } + + function renderDropdown(results) { + const dropdown = $('dg-commander-dropdown'); + + if (results.length === 0) { + dropdown.innerHTML = '
No legendary creatures found
'; + return; + } + + dropdown.innerHTML = results.map((r, idx) => { + const colors = (r.color_identity || []) + .map(c => COLOR_MAP[c] ? '' + COLOR_MAP[c].label + '' : '') + .join(''); + + const badge = r.in_collection + ? 'Owned' + : 'Scryfall'; + + const imgSrc = r.image_url ? r.image_url.replace('version=normal', 'version=small') : ''; + const imgTag = imgSrc + ? '' + : '
'; + + return '
' + + imgTag + + '
' + + '
' + escHtml(r.name) + '
' + + '
' + escHtml(r.type_line || '') + '
' + + '
' + + '
' + colors + '
' + + badge + + '
'; + }).join(''); + + dropdown.querySelectorAll('.dg-dropdown-item').forEach(el => { + el.addEventListener('click', () => { + selectCommander(results[parseInt(el.dataset.idx, 10)]); + hideDropdown(); + }); + }); + } + + function selectCommander(cmdr) { + state.commander = cmdr; + $('dg-commander-input').style.display = 'none'; + $('dg-commander-selected').style.display = 'block'; + $('dg-commander-name').textContent = cmdr.name; + $('dg-commander-type').textContent = cmdr.type_line || ''; + + $('dg-commander-colors').innerHTML = (cmdr.color_identity || []) + .map(c => COLOR_MAP[c] ? '' + COLOR_MAP[c].label + '' : '') + .join(''); + + const img = $('dg-commander-img'); + if (cmdr.image_url) { + var imgUrl = cmdr.image_url; + if (imgUrl.indexOf('version=small') !== -1) { + imgUrl = imgUrl.replace('version=small', 'version=normal'); + } else if (imgUrl.indexOf('version=') === -1 && imgUrl.indexOf('?') !== -1) { + imgUrl += '&version=normal'; + } else if (imgUrl.indexOf('version=') === -1) { + imgUrl += '?version=normal'; + } + img.src = imgUrl; + img.style.display = 'block'; + } else { + img.style.display = 'none'; + } + + $('dg-preview-btn').disabled = false; + + // Auto-fill deck name if empty + const deckNameInput = $('dg-deck-name'); + if (deckNameInput && !deckNameInput.value.trim()) { + deckNameInput.value = 'Auto - ' + cmdr.name; + } + } + + function clearCommander() { + state.commander = null; + $('dg-commander-input').style.display = ''; + $('dg-commander-input').value = ''; + $('dg-commander-selected').style.display = 'none'; + $('dg-preview-btn').disabled = true; + } + + function hideDropdown() { + $('dg-commander-dropdown').classList.remove('open'); + } + + // -- Conversation Log Helper -- + function logMessage(msg, type = 'info') { + const log = $('dg-conversation-log'); + if (!log) return; + const entry = document.createElement('div'); + entry.className = 'dg-log-entry dg-log-' + type; + const ts = new Date().toLocaleTimeString(); + entry.innerHTML = '[' + ts + '] ' + msg; + log.appendChild(entry); + log.scrollTop = log.scrollHeight; + } + + function clearLog() { + const log = $('dg-conversation-log'); + if (!log) return; + log.innerHTML = ''; + } + + // ── Generate Deck ─────────────────────────────────────── + async function generateDeck() { + clearLog(); + logMessage('Connecting to local AI model (gpt-oss:20b)...', 'info'); + if (!state.commander) { toast('Select a commander first', 'error'); return; } + // Non-blocking warning only — local Ollama mode does not require v3Ready + if (!state.v3Ready) { + logMessage('Warning: V3 status check failed. Attempting generation anyway via local AI...', 'warning'); + } + logMessage('Building request body with commander: ' + state.commander.name + '', 'info'); + + setLoading(true, 'Generating deck via Local AI...'); + startProgress(); + + const body = buildRequestBody(); + logMessage('Sending request to AI model... this may take a moment.', 'info'); + state.lastRequestBody = body; + + try { + const resp = await fetch(API_BASE + '/api/deck/v3/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || err.detail || 'Generation failed'); + } + + state.previewResult = await resp.json(); + logMessage('AI response received! Processing deck data...', 'success'); + + const stats = state.previewResult.stats || {}; + const cardCount = (stats.total_cards || 0); + logMessage('Deck generated: ' + cardCount + ' cards | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success'); + stopProgress(true); + renderResults(state.previewResult); + + toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success'); + } catch (e) { + stopProgress(false); + toast(e.message || 'Generation failed', 'error'); + logMessage('Error: ' + (e.message || 'Unknown error'), 'error'); + setLoading(false); + } + } + + function setLoading(loading, text) { + state.isLoading = loading; + $('dg-empty-state').style.display = 'none'; + $('dg-loading').style.display = loading ? 'flex' : 'none'; + $('dg-results').style.display = loading ? 'none' : 'none'; + $('dg-preview-btn').disabled = loading || !state.commander; + if (text) $('dg-loading-text').textContent = text; + } + + // ── Render Results ────────────────────────────────────── + function renderResults(result) { + $('dg-loading').style.display = 'none'; + $('dg-results').style.display = 'block'; + $('dg-preview-btn').disabled = false; + + renderStrategyBar(result); + renderStatsBar(result); + renderSubstitutionSummary(result); + renderCardGroups(result); + renderUseInGameButton(result); + } + + function renderUseInGameButton(result) { + // Only show when running inside the LAN client iframe + if (window.parent === window) return; + + var existing = document.getElementById('dg-export-game-btn'); + if (existing) existing.remove(); + + var btn = document.createElement('button'); + btn.id = 'dg-export-game-btn'; + btn.className = 'dg-btn dg-btn-preview'; + btn.style.cssText = 'margin:12px auto;display:block;border-color:rgba(91,141,217,0.4);color:rgba(91,141,217,0.9);'; + btn.innerHTML = '🎮 Use in Game'; + btn.addEventListener('click', function() { + var cards = (result.cards || []).map(function(c) { + return { name: c.name, quantity: c.count || 1, is_commander: false }; + }); + if (result.commander) { + cards.unshift({ name: result.commander.name, quantity: 1, is_commander: true }); + } + window.parent.postMessage({ + type: 'DECK_BUILDER_EXPORT', + deck: cards, + deckName: result.commander ? result.commander.name + ' Deck' : 'Generated Deck', + commander: result.commander ? result.commander.name : '', + }, '*'); + toast('Deck sent to game setup!', 'success'); + }); + + $('dg-results').appendChild(btn); + } + + function renderStrategyBar(result) { + const bar = $('dg-strategy-bar'); + const bracket = result.bracket || {}; + const archetype = result.archetype || ''; + const strategy = result.strategy_summary || ''; + + bar.innerHTML = '
' + + 'B' + (bracket.level || '?') + '' + + '' + escHtml(archetype) + '' + + '' + escHtml(strategy) + '' + + '
' + + (bracket.reasoning ? '
' + escHtml(bracket.reasoning) + '
' : '') + + (bracket.game_changers && bracket.game_changers.length + ? '
Game Changers: ' + bracket.game_changers.map(c => '' + escHtml(c) + '').join('') + '
' + : ''); + } + + function renderStatsBar(result) { + const stats = result.stats || {}; + const container = $('dg-stats-bar'); + + const chips = [ + chipHtml('Total', stats.total_cards || 0, ''), + chipHtml('Owned', (stats.by_status || {}).owned || 0, 'dg-stat-owned'), + chipHtml('Subbed', (stats.by_status || {}).substituted || 0, 'dg-stat-substituted'), + chipHtml('Missing', (stats.by_status || {}).missing || 0, 'dg-stat-missing'), + chipHtml('Lands', stats.land_count || 0, ''), + chipHtml('$' + (stats.total_price_usd || 0).toFixed(0), 'Total', 'dg-stat-price'), + ]; + + container.innerHTML = chips.join(''); + } + + function chipHtml(label, num, cls) { + return '
' + + '' + num + '' + + '' + label + '' + + '
'; + } + + function renderSubstitutionSummary(result) { + const el = $('dg-sub-summary'); + const sub = result.substitution_stats; + if (!sub) { el.style.display = 'none'; return; } + + el.style.display = 'block'; + el.innerHTML = '
' + + '' + sub.owned + ' owned' + + '' + sub.substituted + ' substituted' + + '' + sub.missing + ' still missing' + + '
'; + } + + function renderCardGroups(result) { + const container = $('dg-card-groups'); + const cards = result.cards || []; + const commander = result.commander; + + const groups = {}; + for (const card of cards) { + const cat = card.category || 'Other'; + if (!groups[cat]) groups[cat] = []; + groups[cat].push(card); + } + + const sortedTypes = TYPE_ORDER.filter(t => groups[t] && groups[t].length > 0); + for (const t of Object.keys(groups)) { + if (!sortedTypes.includes(t)) sortedTypes.push(t); + } + + let html = ''; + + if (commander) { + html += '
' + + '
' + + '⚔ Commander' + + '1' + + '
' + + '
' + + '
' + + '' + escHtml(commander.name) + '' + + 'Commander' + + '
' + + '
'; + } + + for (const type of sortedTypes) { + const groupCards = groups[type]; + if (!groupCards || groupCards.length === 0) continue; + + groupCards.sort((a, b) => (a.name || '').localeCompare(b.name || '')); + + const icon = TYPE_ICONS[type] || '🃏'; + html += '
' + + '
' + + '' + icon + ' ' + escHtml(type) + '' + + '' + groupCards.reduce(function(s,c){ return s + (c.count || 1); }, 0) + '' + + '
' + + '
'; + + for (const card of groupCards) { + html += renderCardRow(card); + } + + html += '
'; + } + + container.innerHTML = html; + + container.querySelectorAll('.dg-group-header').forEach(header => { + header.addEventListener('click', () => { + const type = header.dataset.type; + if (!type) return; + const body = container.querySelector('.dg-group-body[data-group="' + type + '"]'); + if (body) body.classList.toggle('collapsed'); + }); + }); + } + + function renderCardRow(card) { + const statusInfo = STATUS_BADGES[card.status] || STATUS_BADGES.missing; + const statusBadge = '' + statusInfo.label + ''; + + const roleTags = (card.role_tags || []).slice(0, 3) + .map(r => '' + escHtml(r) + '') + .join(''); + + let subHtml = ''; + if (card.status === 'substituted' && card.selected_substitute) { + subHtml = '
' + + '→ ' + + '' + escHtml(card.selected_substitute) + '' + + '
'; + } else if (card.status === 'missing' && card.alternatives && card.alternatives.length > 0) { + subHtml = '
' + + 'Alternatives: ' + + card.alternatives.slice(0, 3).map(a => + '' + + escHtml(a.name) + ' (' + (a.similarity_score * 100).toFixed(0) + '%)' + + '' + ).join('') + + '
'; + } + + const price = card.estimated_price_usd + ? '$' + card.estimated_price_usd.toFixed(2) + '' + : ''; + + return '
' + + '
' + + '' + ((card.count || 1) > 1 ? '' + card.count + 'x ' : '') + escHtml(card.name) + '' + + '' + roleTags + '' + + statusBadge + + price + + '
' + + (card.reason ? '
' + escHtml(card.reason) + '
' : '') + + subHtml + + '
'; + } + + // ── Commit Deck ───────────────────────────────────────── + async function commitDeck() { + if (!state.commander) { toast('No commander selected', 'error'); return; } + + $('dg-save-btn').disabled = true; + $('dg-save-btn').textContent = 'Saving...'; + + try { + const body = state.lastRequestBody || buildRequestBody(); + const resp = await fetch(API_BASE + '/api/deck/v3/commit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || err.detail || 'Save failed'); + } + + const result = await resp.json(); + toast('Deck saved: ' + (result.deck_name || 'Unknown'), 'success'); + + setTimeout(() => { + window.location.href = 'deckbuilder.html?deck_id=' + result.deck_id; + }, 1200); + } catch (e) { + toast(e.message || 'Save failed', 'error'); + } finally { + $('dg-save-btn').disabled = false; + $('dg-save-btn').innerHTML = '💾 Save to Deck Builder'; + } + } + + // ── Exports ───────────────────────────────────────────── + async function exportDeck(format) { + if (!state.commander) { toast('Generate a deck first', 'error'); return; } + + const body = state.lastRequestBody || buildRequestBody(); + + try { + const resp = await fetch(API_BASE + '/api/deck/v3/export/' + format, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!resp.ok) throw new Error('Export failed'); + + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = (state.commander.name || 'deck').replace(/\s+/g, '_') + '.' + (format === 'csv' ? 'csv' : 'dck'); + a.click(); + URL.revokeObjectURL(url); + + toast('Exported ' + format.toUpperCase(), 'success'); + } catch (e) { + toast(e.message || 'Export failed', 'error'); + } + } + + async function exportMoxfield() { + if (!state.commander) { toast('Generate a deck first', 'error'); return; } + + const body = state.lastRequestBody || buildRequestBody(); + + try { + const resp = await fetch(API_BASE + '/api/deck/v3/export/moxfield', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await resp.json(); + if (data.content) { + await navigator.clipboard.writeText(data.content); + toast('Moxfield deck copied to clipboard', 'success'); + } + } catch (e) { + toast('Moxfield export failed', 'error'); + } + } + + async function exportShopping() { + if (!state.commander) { toast('Generate a deck first', 'error'); return; } + + const body = state.lastRequestBody || buildRequestBody(); + + try { + const resp = await fetch(API_BASE + '/api/deck/v3/export/shopping', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await resp.json(); + const list = data.shopping_list || []; + + if (list.length === 0) { + toast('All cards are owned — nothing to buy', 'success'); + return; + } + + const lines = ['Shopping List — ' + (data.commander || 'Deck'), '']; + for (const item of list) { + lines.push(item.count + 'x ' + item.name + ' ($' + (item.estimated_price_usd || 0).toFixed(2) + ') — ' + item.category); + } + lines.push('', 'Total: ' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(2)); + + await navigator.clipboard.writeText(lines.join('\n')); + toast('Shopping list copied (' + list.length + ' cards, ~$' + (data.estimated_cost_usd || 0).toFixed(0) + ')', 'success'); + } catch (e) { + toast('Shopping list failed', 'error'); + } + } + + // ── Toast ─────────────────────────────────────────────── + function toast(msg, type) { + type = type || 'info'; + const container = $('dg-toast-container'); + const el = document.createElement('div'); + el.className = 'dg-toast dg-toast-' + type; + el.textContent = msg; + container.appendChild(el); + setTimeout(() => el.remove(), 4000); + } + + // ── Helpers ───────────────────────────────────────────── + function escHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function escAttr(str) { + return str.replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>'); + } + + return { init }; +})(); + +document.addEventListener('DOMContentLoaded', () => { + DeckGenerator.init(); +}); diff --git a/lan-client/engine.js b/lan-client/engine.js index 3093d28..e35af64 100644 --- a/lan-client/engine.js +++ b/lan-client/engine.js @@ -2088,7 +2088,7 @@ function autoResolveTriggerEffect(trigger) { // Helper: find the card on battlefield for counter placement function findCardOnBattlefield(cardName, ownerIdx) { - var bf = (gameState.players[ownerIdx] || {}).battlefield || []; + var bf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === ownerIdx; }); for (var i = 0; i < bf.length; i++) { if (bf[i].name === cardName) return bf[i]; } @@ -2233,7 +2233,7 @@ function autoResolveTriggerEffect(trigger) { var opps4 = getOpponents(oi); var placed = false; for (var cmi = 0; cmi < opps4.length && !placed; cmi++) { - var cmBf = (gameState.players[opps4[cmi]] || {}).battlefield || []; + var cmBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === opps4[cmi]; }); for (var cmj = 0; cmj < cmBf.length && !placed; cmj++) { if (cmBf[cmj].type_line && cmBf[cmj].type_line.toLowerCase().indexOf('creature') !== -1) { cmBf[cmj].counters = cmBf[cmj].counters || {}; @@ -2250,7 +2250,7 @@ function autoResolveTriggerEffect(trigger) { case 'counter_all': { // +1/+1 counter on each creature you control - var caBf = (gameState.players[oi] || {}).battlefield || []; + var caBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; }); var caCount = 0; for (var cai = 0; cai < caBf.length; cai++) { if (caBf[cai].type_line && caBf[cai].type_line.toLowerCase().indexOf('creature') !== -1) { @@ -2362,7 +2362,7 @@ function autoResolveTriggerEffect(trigger) { } case 'untap_all': { - var uaBf = (gameState.players[oi] || {}).battlefield || []; + var uaBf = gameState.battlefieldCards.filter(function(c) { return c.ownerIndex === oi; }); var uaCount = 0; for (var uai = 0; uai < uaBf.length; uai++) { if (uaBf[uai].tapped) { @@ -12779,6 +12779,9 @@ async function loadPreconForPlayer(playerIdx, preconId) { if (commandCards.length > 0) { player.commanderCardName = commandCards[0].name || ''; } + if (precon.fileName) { + player.deckFileName = precon.fileName; + } // Shuffle library for (var si = libraryCards.length - 1; si > 0; si--) { @@ -16225,6 +16228,72 @@ function aiPickMultiTargetAttacks(playerIdx, creatures, difficulty) { return assignments; } +// ---- AI Helper: Declare attacks for an AI player (used by ai-bridge) ---- +function aiDeclareAttacks(playerIdx) { + if (!gameState) return; + var player = gameState.players[playerIdx]; + if (!player || player.eliminated) return; + var difficulty = gameState.aiDifficulty || 'easy'; + + var creatures = aiGetUntappedCreatures(playerIdx).filter(function(c) { + return !hasSummoningSickness(c); + }); + if (creatures.length === 0) return; + + var attackerCards = []; + + if (difficulty === 'easy') { + attackerCards = creatures.filter(function() { return Math.random() > 0.5; }); + if (attackerCards.length === 0 && creatures.length > 0) attackerCards = [creatures[0]]; + } else { + var primaryTarget = aiPickAttackTarget(playerIdx); + if (primaryTarget === -1) return; + + creatures.forEach(function(c) { + var eval_ = aiEvaluateAttack(c, primaryTarget); + if (difficulty === 'normal') { + if (eval_.shouldAttack || eval_.risk === 'trade') attackerCards.push(c); + } else { + if (eval_.shouldAttack) attackerCards.push(c); + else if (creatures.length >= 4 && eval_.risk === 'bad-trade') attackerCards.push(c); + } + }); + + if (difficulty === 'hard' && attackerCards.length > 2) { + var sortedByToughness = attackerCards.slice().sort(function(a, b) { + return getCreatureToughness(b) - getCreatureToughness(a); + }); + var holdBack = sortedByToughness.find(function(c) { + return !hasKeyword(c, 'vigilance') && getCreatureToughness(c) >= 3; + }); + if (holdBack) { + attackerCards = attackerCards.filter(function(c) { return c.id !== holdBack.id; }); + } + } + } + + if (attackerCards.length === 0) return; + + var assignments = aiPickMultiTargetAttacks(playerIdx, attackerCards, difficulty); + if (assignments.length === 0) return; + + gameState.combat.attackers = assignments.map(function(a) { + return { cardId: a.card.id, targetPlayerId: a.targetIdx }; + }); + + attackerCards.forEach(function(c) { + if (!hasKeyword(c, 'vigilance')) { + c.tapped = true; + var el = document.getElementById('bc-' + c.id); + if (el) el.classList.add('tapped'); + } + var el2 = document.getElementById('bc-' + c.id); + if (el2) el2.classList.add('attacking'); + }); + + fireBattlefieldTriggers('attack'); +} + // ---- AI Helper: Check if AI has a counterspell and should use it ---- function aiCheckForCounterspell(playerIdx) { if (!gameState) return null; @@ -16593,7 +16662,7 @@ function _aiRunPostCombat(playerIdx, player, difficulty, heldCards) { var postDelay = 600; // MAIN PHASE 2 (same logic as inline version) - if (difficulty !== 'easy') { + { postActions.push({ delay: postDelay, fn: function() { var bluffReserve = 0; var bluffCardName = ''; @@ -17205,12 +17274,12 @@ function _runLocalAiTurn() { } // ======================================== - // MAIN PHASE 2: Post-combat spell casting (normal & hard) + // MAIN PHASE 2: Post-combat spell casting // Hard: play held-back removal + instants + any remaining affordable spells - // Normal: try to play any remaining affordable spells + // Normal/Easy: try to play any remaining affordable spells // BLUFFING: On Normal/Hard, hold back mana if we have instant-speed answers // ======================================== - if (difficulty !== 'easy') { + { actions.push({ delay: delay, fn: function() { if (_aiBlockerPauseState) return; // Skip — human blocker flow handles this var bluffReserve = 0; @@ -25634,3 +25703,9 @@ function _safeRender(fn, ...args) { try { window[fn](...args); } catch(e) {} } } + +// Expose functions needed by ai-bridge.js +window.aiTryCastCommander = aiTryCastCommander; +window.aiDeclareAttacks = aiDeclareAttacks; +window.playCardFromHand = playCardFromHand; +window.aiCountUntappedLands = aiCountUntappedLands; diff --git a/lan-client/global.css b/lan-client/global.css new file mode 100644 index 0000000..79acbab --- /dev/null +++ b/lan-client/global.css @@ -0,0 +1,955 @@ +/* ═══════════════════════════════════════════════════════════ + Commander AI Lab — Global Design System + Dark & Polished · Premium Dashboard Aesthetic + ═══════════════════════════════════════════════════════════ */ + +/* ── Reset ──────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +/* ── Design Tokens ──────────────────────────────────────── */ +:root { + /* Surfaces — layered dark with subtle warmth */ + --bg: #0c0e14; + --surface-0: #12141c; + --surface-1: #171a24; + --surface-2: #1c1f2b; + --surface-3: #222533; + --surface-hover: #252839; + + /* Borders */ + --border: #2a2d3a; + --border-subtle: #1e2130; + --border-focus: #4a6fa5; + + /* Text */ + --text: #e1e4eb; + --text-secondary:#8b90a0; + --text-tertiary: #5c6070; + --text-inverse: #0c0e14; + + /* Accent — cool blue with subtle depth */ + --accent: #5b9ef0; + --accent-hover: #7ab4ff; + --accent-dim: rgba(91, 158, 240, 0.12); + --accent-glow: rgba(91, 158, 240, 0.25); + + /* Semantic */ + --success: #4ade80; + --success-dim: rgba(74, 222, 128, 0.12); + --warning: #fbbf24; + --warning-dim: rgba(251, 191, 36, 0.12); + --error: #f87171; + --error-dim: rgba(248, 113, 113, 0.12); + --purple: #a78bfa; + --purple-dim: rgba(167, 139, 250, 0.12); + --gold: #fcd34d; + + /* Radius */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + + /* Shadows */ + --shadow-sm: 0 1px 3px rgba(0,0,0,0.3); + --shadow-md: 0 4px 16px rgba(0,0,0,0.35); + --shadow-lg: 0 12px 40px rgba(0,0,0,0.45); + --shadow-glow: 0 0 20px var(--accent-dim); + + /* Transitions */ + --ease: cubic-bezier(0.16, 1, 0.3, 1); + --duration: 180ms; + + /* Typography */ + --font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; + + /* Spacing scale */ + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; + --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; + --sp-8: 32px; --sp-10: 40px; --sp-12: 48px; + --sp-16: 64px; + + /* Layout */ + --content-max: 1280px; + --sidebar-width: 280px; + --nav-height: 56px; +} + +/* ── Base ───────────────────────────────────────────────── */ +html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + scroll-behavior: smooth; +} + +body { + min-height: 100vh; + font-family: var(--font); + font-size: 14px; + line-height: 1.6; + color: var(--text); + background: var(--bg); +} + +h1, h2, h3, h4, h5, h6 { + color: #fff; + font-weight: 600; + line-height: 1.25; +} + +h1 { font-size: 24px; } +h2 { font-size: 18px; } +h3 { font-size: 16px; } + +a { color: var(--accent); text-decoration: none; transition: color var(--duration) var(--ease); } +a:hover { color: var(--accent-hover); } + +img, svg { display: block; max-width: 100%; } +button { cursor: pointer; font: inherit; } +input, select, textarea { font: inherit; color: inherit; } +table { border-collapse: collapse; width: 100%; } + +::selection { + background: var(--accent-dim); + color: #fff; +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +/* ── Scrollbar ──────────────────────────────────────────── */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); } + +/* ── Navigation ─────────────────────────────────────────── */ +.nav { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + height: var(--nav-height); + padding: 0 var(--sp-6); + background: rgba(12, 14, 20, 0.85); + backdrop-filter: blur(16px) saturate(1.5); + -webkit-backdrop-filter: blur(16px) saturate(1.5); + border-bottom: 1px solid var(--border-subtle); +} + +.nav-brand { + display: flex; + align-items: center; + gap: var(--sp-3); + font-size: 15px; + font-weight: 700; + color: #fff; + letter-spacing: -0.01em; + text-decoration: none; + white-space: nowrap; + margin-right: var(--sp-8); +} + +.nav-brand svg { + width: 28px; + height: 28px; + flex-shrink: 0; +} + +.nav-links { + display: flex; + align-items: center; + gap: var(--sp-1); + overflow-x: auto; + scrollbar-width: none; +} + +.nav-links::-webkit-scrollbar { display: none; } + +.nav-links a { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: 6px 12px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + border-radius: var(--radius-sm); + white-space: nowrap; + transition: all var(--duration) var(--ease); + text-decoration: none; +} + +.nav-links a:hover { + color: var(--text); + background: var(--surface-2); +} + +.nav-links a.active { + color: #fff; + background: var(--accent-dim); +} + +.nav-links a .nav-icon { + width: 16px; + height: 16px; + opacity: 0.7; +} + +.nav-links a.active .nav-icon, +.nav-links a:hover .nav-icon { + opacity: 1; +} + +/* ── Page Layout ────────────────────────────────────────── */ +.page { + max-width: var(--content-max); + margin: 0 auto; + padding: var(--sp-8) var(--sp-6); +} + +.page-header { + margin-bottom: var(--sp-8); +} + +.page-header h1 { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: var(--sp-2); +} + +.page-header p { + color: var(--text-secondary); + font-size: 15px; +} + +/* ── Cards / Panels ─────────────────────────────────────── */ +.panel { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--sp-6); + margin-bottom: var(--sp-6); + transition: border-color var(--duration) var(--ease); +} + +.panel:hover { + border-color: var(--border-focus); +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--sp-5); +} + +.panel-header h2 { + font-size: 16px; + font-weight: 600; +} + +.panel-header .badge { + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border-radius: var(--radius-sm); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* ── Stat Cards ─────────────────────────────────────────── */ +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--sp-4); + margin-bottom: var(--sp-6); +} + +.stat-card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--sp-5); + transition: all var(--duration) var(--ease); +} + +.stat-card:hover { + border-color: var(--border-focus); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.stat-label { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: var(--sp-2); +} + +.stat-value { + font-size: 28px; + font-weight: 700; + color: #fff; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.stat-sub { + font-size: 12px; + color: var(--text-tertiary); + margin-top: var(--sp-1); +} + +/* ── Buttons ────────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: 8px 18px; + font-size: 13px; + font-weight: 600; + border-radius: var(--radius-sm); + border: none; + cursor: pointer; + transition: all var(--duration) var(--ease); + white-space: nowrap; + line-height: 1.4; +} + +.btn-primary { + background: var(--accent); + color: var(--text-inverse); +} + +.btn-primary:hover { + background: var(--accent-hover); + box-shadow: 0 4px 14px var(--accent-glow); + transform: translateY(-1px); +} + +.btn-primary:active { + transform: translateY(0); +} + +.btn-primary:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.btn-secondary { + background: var(--surface-3); + color: var(--text); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + background: var(--surface-hover); + border-color: var(--text-tertiary); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + padding: 6px 12px; +} + +.btn-ghost:hover { + color: var(--text); + background: var(--surface-2); +} + +.btn-success { background: var(--success); color: var(--text-inverse); } +.btn-success:hover { box-shadow: 0 4px 14px var(--success-dim); } + +.btn-purple { background: var(--purple); color: var(--text-inverse); } +.btn-purple:hover { box-shadow: 0 4px 14px var(--purple-dim); } + +.btn-danger { background: var(--error); color: #fff; } +.btn-danger:hover { box-shadow: 0 4px 14px var(--error-dim); } + +.btn-sm { padding: 5px 12px; font-size: 12px; } +.btn-lg { padding: 12px 24px; font-size: 15px; } +.btn-icon-only { padding: 8px; width: 36px; height: 36px; } + +/* ── Forms ──────────────────────────────────────────────── */ +.form-group { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.form-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 9px 14px; + font-size: 14px; + color: var(--text); + background: var(--surface-0); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + transition: all var(--duration) var(--ease); +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-dim); + outline: none; +} + +.form-input::placeholder, +.form-textarea::placeholder { + color: var(--text-tertiary); +} + +.form-select { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%235c6070' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 36px; +} + +.form-textarea { + resize: vertical; + min-height: 100px; +} + +.form-checkbox { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; +} + +.form-checkbox input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--sp-4); +} + +/* ── Progress Bar ───────────────────────────────────────── */ +.progress-container { + margin-top: var(--sp-4); + padding: var(--sp-4); + background: var(--surface-0); + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); +} + +.progress-header { + display: flex; + align-items: center; + gap: var(--sp-3); + margin-bottom: var(--sp-3); +} + +.progress-label { + font-size: 13px; + font-weight: 500; + color: var(--text); +} + +.progress-track { + height: 6px; + background: var(--surface-3); + border-radius: 3px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + border-radius: 3px; + transition: width 0.3s var(--ease); +} + +.progress-detail { + margin-top: var(--sp-2); + font-size: 12px; + font-family: var(--font-mono); + color: var(--text-tertiary); + line-height: 1.6; +} + +/* ── Phase Badge ────────────────────────────────────────── */ +.badge-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border-radius: var(--radius-sm); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.badge-status::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.badge-idle { background: var(--surface-3); color: var(--text-secondary); } +.badge-idle::before { background: var(--text-tertiary); } + +.badge-running { background: var(--accent-dim); color: var(--accent); } +.badge-running::before { background: var(--accent); animation: pulse-dot 1.5s ease-in-out infinite; } + +.badge-success { background: var(--success-dim); color: var(--success); } +.badge-success::before { background: var(--success); } + +.badge-error { background: var(--error-dim); color: var(--error); } +.badge-error::before { background: var(--error); } + +.badge-warning { background: var(--warning-dim); color: var(--warning); } +.badge-warning::before { background: var(--warning); } + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* ── Empty State ────────────────────────────────────────── */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--sp-12) var(--sp-6); + text-align: center; + color: var(--text-tertiary); +} + +.empty-state svg { + width: 48px; + height: 48px; + margin-bottom: var(--sp-4); + opacity: 0.3; +} + +.empty-state p { + font-size: 14px; + margin-bottom: var(--sp-3); +} + +/* ── Tables ─────────────────────────────────────────────── */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.data-table thead th { + text-align: left; + padding: var(--sp-3) var(--sp-4); + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; + border-bottom: 1px solid var(--border); + background: var(--surface-0); +} + +.data-table tbody td { + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--border-subtle); + vertical-align: middle; +} + +.data-table tbody tr:hover { + background: var(--surface-2); +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +/* ── Tags ───────────────────────────────────────────────── */ +.tag { + display: inline-flex; + align-items: center; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + border-radius: var(--radius-sm); + white-space: nowrap; +} + +.tag-blue { background: var(--accent-dim); color: var(--accent); } +.tag-green { background: var(--success-dim); color: var(--success); } +.tag-yellow { background: var(--warning-dim); color: var(--warning); } +.tag-red { background: var(--error-dim); color: var(--error); } +.tag-purple { background: var(--purple-dim); color: var(--purple); } + +/* ── Toast / Notification ───────────────────────────────── */ +.toast { + position: fixed; + bottom: var(--sp-6); + right: var(--sp-6); + padding: var(--sp-3) var(--sp-5); + background: var(--surface-3); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + font-size: 13px; + color: var(--text); + z-index: 1000; + animation: toast-in 0.3s var(--ease); +} + +.toast-success { border-left: 3px solid var(--success); } +.toast-error { border-left: 3px solid var(--error); } +.toast-warning { border-left: 3px solid var(--warning); } + +@keyframes toast-in { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Loading Spinner ────────────────────────────────────── */ +.spinner { + width: 20px; + height: 20px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ── Divider ────────────────────────────────────────────── */ +.divider { + height: 1px; + background: var(--border); + margin: var(--sp-5) 0; +} + +/* ── Utility Classes ────────────────────────────────────── */ +.hidden { display: none !important; } +.text-accent { color: var(--accent); } +.text-success { color: var(--success); } +.text-warning { color: var(--warning); } +.text-error { color: var(--error); } +.text-muted { color: var(--text-secondary); } +.text-faint { color: var(--text-tertiary); } +.text-mono { font-family: var(--font-mono); } +.text-sm { font-size: 12px; } +.text-xs { font-size: 11px; } +.font-bold { font-weight: 700; } +.text-center { text-align: center; } +.text-right { text-align: right; } +.mt-2 { margin-top: var(--sp-2); } +.mt-4 { margin-top: var(--sp-4); } +.mt-6 { margin-top: var(--sp-6); } +.mb-2 { margin-bottom: var(--sp-2); } +.mb-4 { margin-bottom: var(--sp-4); } +.mb-6 { margin-bottom: var(--sp-6); } +.gap-2 { gap: var(--sp-2); } +.gap-4 { gap: var(--sp-4); } +.flex { display: flex; } +.flex-col { flex-direction: column; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.flex-1 { flex: 1; } +.grid { display: grid; } +.sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0; +} + +/* ── Responsive ─────────────────────────────────────────── */ +@media (max-width: 768px) { + .page { + padding: var(--sp-5) var(--sp-4); + } + + .nav { + padding: 0 var(--sp-4); + } + + .nav-brand { + margin-right: var(--sp-4); + font-size: 13px; + } + + .stat-grid { + grid-template-columns: repeat(2, 1fr); + } + + .form-row { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .stat-grid { + grid-template-columns: 1fr; + } +} + +/* ═══════════════════════════════════════════════════════════ + PREMIUM UPGRADE LAYER + ═══════════════════════════════════════════════════════════ */ + +/* ── Ambient background glow (body) ─────────────────────── */ +body { + background-image: + radial-gradient(ellipse 80% 50% at 10% -10%, rgba(91,158,240,0.07) 0%, transparent 60%), + radial-gradient(ellipse 60% 40% at 90% 110%, rgba(167,139,250,0.05) 0%, transparent 60%); + background-attachment: fixed; +} + +/* ── Page title: subtle gradient text ───────────────────── */ +.page-header h1 { + background: linear-gradient(135deg, #fff 60%, rgba(91,158,240,0.85) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ── Nav: stronger glass + accent underline ─────────────── */ +.nav { + background: rgba(9,11,16,0.80); + backdrop-filter: blur(20px) saturate(1.8); + -webkit-backdrop-filter: blur(20px) saturate(1.8); + box-shadow: 0 1px 0 rgba(91,158,240,0.08), 0 4px 24px rgba(0,0,0,0.3); + border-bottom-color: rgba(255,255,255,0.06); +} +.nav-brand svg { filter: drop-shadow(0 0 8px rgba(91,158,240,0.4)); } +.nav-links a.active { box-shadow: inset 0 0 0 1px rgba(91,158,240,0.2); } + +/* ── Panels: gradient border on hover ───────────────────── */ +.panel { + position: relative; + transition: border-color var(--duration) var(--ease), + box-shadow var(--duration) var(--ease); +} +.panel::before { + content: ''; + position: absolute; + inset: 0; + border-radius: var(--radius-lg); + padding: 1px; + background: linear-gradient(135deg, rgba(91,158,240,0.18), transparent 50%, rgba(167,139,250,0.1)); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + opacity: 0; + transition: opacity var(--duration) var(--ease); +} +.panel:hover::before { opacity: 1; } +.panel:hover { + box-shadow: 0 8px 32px rgba(0,0,0,0.35), 0 0 0 1px rgba(91,158,240,0.08); +} + +/* ── Stat cards: gradient surface + shimmer top edge ─────── */ +.stat-card { + position: relative; + background: linear-gradient(145deg, var(--surface-2), var(--surface-1)); + overflow: hidden; +} +.stat-card::after { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent); + pointer-events: none; +} +.stat-card:hover { + box-shadow: var(--shadow-md), 0 0 20px rgba(91,158,240,0.08); + transform: translateY(-2px); +} + +/* ── Buttons: gradient fills + inner highlight ───────────── */ +.btn { position: relative; overflow: hidden; } +.btn::after { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 50%; + background: linear-gradient(to bottom, rgba(255,255,255,0.07), transparent); + border-radius: inherit; + pointer-events: none; +} +.btn-primary { + background: linear-gradient(135deg, var(--accent), #4a8de0); + box-shadow: 0 2px 8px rgba(91,158,240,0.25); +} +.btn-primary:hover { + background: linear-gradient(135deg, var(--accent-hover), var(--accent)); + box-shadow: 0 4px 20px rgba(91,158,240,0.4); +} +.btn-success { + background: linear-gradient(135deg, var(--success), #38c46c); + box-shadow: 0 2px 8px rgba(74,222,128,0.2); +} +.btn-success:hover { box-shadow: 0 4px 20px rgba(74,222,128,0.4); transform: translateY(-1px); } +.btn-purple { + background: linear-gradient(135deg, var(--purple), #9171f0); + box-shadow: 0 2px 8px rgba(167,139,250,0.2); +} +.btn-purple:hover { box-shadow: 0 4px 20px rgba(167,139,250,0.4); transform: translateY(-1px); } + +/* ── Tags: subtle colored border ────────────────────────── */ +.tag-blue { border: 1px solid rgba(91,158,240,0.25); } +.tag-green { border: 1px solid rgba(74,222,128,0.25); } +.tag-yellow { border: 1px solid rgba(251,191,36,0.25); } +.tag-red { border: 1px solid rgba(248,113,113,0.25); } +.tag-purple { border: 1px solid rgba(167,139,250,0.25); } + +/* ── Progress fill: glow ─────────────────────────────────── */ +.progress-fill { box-shadow: 0 0 8px rgba(91,158,240,0.5); } + +/* ── Badge running: glow dot ─────────────────────────────── */ +.badge-running { box-shadow: 0 0 12px rgba(91,158,240,0.15); } +.badge-running::before { box-shadow: 0 0 6px var(--accent); } + +/* ── Toast: glassmorphism + colored glow ─────────────────── */ +.toast { backdrop-filter: blur(12px); } +.toast-success { box-shadow: var(--shadow-lg), 0 0 20px rgba(74,222,128,0.1); } +.toast-error { box-shadow: var(--shadow-lg), 0 0 20px rgba(248,113,113,0.1); } + +/* ── Divider: fade-out gradient ──────────────────────────── */ +.divider { + background: linear-gradient(90deg, transparent, var(--border), transparent); +} + +/* ── Inputs: inset shadow for depth ─────────────────────── */ +.form-input, .form-select, .form-textarea { + box-shadow: inset 0 1px 3px rgba(0,0,0,0.25); +} +.form-input:focus, .form-select:focus, .form-textarea:focus { + box-shadow: 0 0 0 3px var(--accent-dim), inset 0 1px 3px rgba(0,0,0,0.1); +} + + +/* ══════════════════════════════════════════════════════════════ + Tooltip System (.tip-icon + data-tip) + Shared across all pages — do not duplicate in page-specific CSS + ══════════════════════════════════════════════════════════════ */ + +.tip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + border-radius: 50%; + background: var(--surface-3); + color: var(--text-secondary); + font-size: 10px; + font-weight: 700; + font-style: normal; + cursor: default; + position: relative; + vertical-align: middle; + margin-left: 4px; + flex-shrink: 0; + transition: background 0.15s, color 0.15s; + user-select: none; +} + +.tip-icon:hover { + background: var(--accent-dim); + color: var(--accent); +} + +.tip-icon::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--surface-0, #1a1a2e); + color: var(--text, #e0e0e0); + border: 1px solid var(--border, #333); + border-radius: var(--radius-md, 6px); + padding: 8px 12px; + font-size: 12px; + font-weight: 400; + line-height: 1.5; + white-space: normal; + width: 240px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; + z-index: 1000; + text-align: left; + text-transform: none; + letter-spacing: 0; +} + +.tip-icon::before { + content: ''; + position: absolute; + bottom: calc(100% + 3px); + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: var(--border, #333); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; + z-index: 1001; +} + +.tip-icon:hover::after, +.tip-icon:hover::before { + opacity: 1; +} + +.tip-icon.tip-below::after { + bottom: auto; + top: calc(100% + 8px); +} + +.tip-icon.tip-below::before { + bottom: auto; + top: calc(100% + 3px); + border-top-color: transparent; + border-bottom-color: var(--border, #333); +} diff --git a/lan-client/index.html b/lan-client/index.html index 6460f15..18f1167 100644 --- a/lan-client/index.html +++ b/lan-client/index.html @@ -51,6 +51,9 @@ + + + @@ -688,10 +691,122 @@ document.addEventListener('DOMContentLoaded', function() { indicator.className = 'backend-status ' + (status.connected ? (status.modelLoaded ? 'connected-ml' : 'connected') : 'disconnected'); } + + // Wire up WebSocket push events from backend + AIBridge.connector.on('ai:decision', function(msg) { + if (!gameState || !msg.action) return; + var playerIdx = gameState.currentPlayerIndex; + var player = gameState.players[playerIdx]; + if (player && player.isAI) { + AIBridge.MacroActionMapper.execute(msg.action, playerIdx, gameState, msg); + } + }); + + AIBridge.connector.on('state:snapshot', function(msg) { + if (typeof AI_BRIDGE_CONFIG !== 'undefined' && AI_BRIDGE_CONFIG.verbose) { + console.log('[WS] State snapshot received from backend'); + } + }); }); } }); + + + + + + + + + diff --git a/lan-client/precon-loader.js b/lan-client/precon-loader.js index 8a8427a..bc6ac3b 100644 --- a/lan-client/precon-loader.js +++ b/lan-client/precon-loader.js @@ -214,6 +214,7 @@ async function _fetchScryfallBatch(names) { typeLine: card.type_line || '', oracleText: card.oracle_text || (card.card_faces && card.card_faces[0] ? card.card_faces[0].oracle_text : '') || '', manaCost: card.mana_cost || (card.card_faces && card.card_faces[0] ? card.card_faces[0].mana_cost : '') || '', + cmc: card.cmc || 0, power: card.power || '', toughness: card.toughness || '', }; @@ -221,7 +222,7 @@ async function _fetchScryfallBatch(names) { // Mark not-found cards so we don't re-fetch them on next enrichment for (const name of names) { - if (!_scryfallCache[name]) _scryfallCache[name] = { imageUrl: '', typeLine: '', oracleText: '', manaCost: '', power: '', toughness: '' }; + if (!_scryfallCache[name]) _scryfallCache[name] = { imageUrl: '', typeLine: '', oracleText: '', manaCost: '', cmc: 0, power: '', toughness: '' }; } } @@ -236,8 +237,15 @@ function _applyScryfallDataToAllCards() { card.typeLine = data.typeLine || card.typeLine; card.oracleText = data.oracleText || card.oracleText; card.manaCost = data.manaCost || card.manaCost; + card.cmc = data.cmc || card.cmc || 0; if (data.power) card.power = data.power; if (data.toughness) card.toughness = data.toughness; + + // Tag roles from oracle text so aiCategorizeHand works + const ot = (card.oracleText || '').toLowerCase(); + if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot); + if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot); + if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot); } } } @@ -246,11 +254,17 @@ function _applyScryfallDataToAllCards() { for (const card of gameState.battlefieldCards) { if (card.ownerIndex !== player.id) continue; const data = _scryfallCache[card.name]; - if (data && !card.imageUrl) { - card.imageUrl = data.imageUrl || ''; + if (data) { + if (!card.imageUrl) card.imageUrl = data.imageUrl || ''; card.typeLine = data.typeLine || card.typeLine; card.oracleText = data.oracleText || card.oracleText; card.manaCost = data.manaCost || card.manaCost; + card.cmc = data.cmc || card.cmc || 0; + + const ot = (card.oracleText || '').toLowerCase(); + if (!card.isRamp) card.isRamp = /search your library for a.*land|add \{|adds? one mana/i.test(ot); + if (!card.isRemoval) card.isRemoval = /destroy target|exile target|counter target spell/i.test(ot); + if (!card.isBoardWipe) card.isBoardWipe = /destroy all|exile all|all creatures get -/i.test(ot); } } } diff --git a/lan-client/style.css b/lan-client/style.css index 4abbabd..57aa678 100644 --- a/lan-client/style.css +++ b/lan-client/style.css @@ -3541,3 +3541,35 @@ kbd { border-color: rgba(255,255,255,0.2); color: var(--color-text-muted); } + +/* ── Fullscreen Panel (Deck Builder / Deck Generator) ──── */ +.fullscreen-panel { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 9000; + background: #0a0a14; + display: flex; + flex-direction: column; +} +.fullscreen-panel.hidden { + display: none; +} +.fullscreen-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 36px; + padding: 0 12px; + background: rgba(20,20,35,0.95); + border-bottom: 1px solid rgba(255,255,255,0.08); + color: rgba(255,255,255,0.8); + font-size: 13px; + font-weight: 600; +} +.fullscreen-panel-header .btn-util { + font-size: 18px; + line-height: 1; +} diff --git a/lan-client/ui.js b/lan-client/ui.js index f87a870..87b27a2 100644 --- a/lan-client/ui.js +++ b/lan-client/ui.js @@ -150,23 +150,13 @@ function updateZoneCountsForPlayer(playerIdx) { const libCount = document.getElementById('lib-count-' + pNum); if (libCount) libCount.textContent = player.zones.library.length; - // Update exile count - const exCount = document.getElementById('exile-count-' + pNum); - if (exCount) { - exCount.textContent = player.zones.exile.length; - exCount.style.display = player.zones.exile.length > 0 ? 'block' : 'none'; - } - - // Update graveyard count - const gvCount = document.getElementById('gv-count-' + pNum); - if (gvCount) { - gvCount.textContent = player.zones.graveyard.length; - gvCount.style.display = player.zones.graveyard.length > 0 ? 'block' : 'none'; - } - // Update hand count const handCount = document.getElementById('hand-count-' + pNum); if (handCount) handCount.textContent = player.zones.hand.length; + + // Exile + Graveyard: render last card image face-up + renderLastCardInZone(playerIdx, 'exile'); + renderLastCardInZone(playerIdx, 'graveyard'); } function updatePlayerZones() { @@ -217,6 +207,50 @@ function renderCommanderZone(playerIdx) { } } +// ============================================================ +// EXILE / GRAVEYARD — Show last card image face-up +// ============================================================ +function renderLastCardInZone(playerIdx, zoneName) { + if (!gameState) return; + const player = gameState.players[playerIdx]; + if (!player) return; + const pNum = playerIdx + 1; + + const zoneIdPrefix = (zoneName === 'exile') ? 'exile-zone-' : 'gv-zone-'; + const zoneEl = document.getElementById(zoneIdPrefix + pNum); + if (!zoneEl) return; + + const cards = player.zones[zoneName] || []; + const lastCard = cards[cards.length - 1]; // most recently added card + + if (lastCard && lastCard.imageUrl) { + const safeImg = (lastCard.imageUrl || '').replace(/'/g, "\\'"); + const safeName = (lastCard.name || '').replace(/'/g, "\\'"); + const safeType = (lastCard.typeLine || '').replace(/'/g, "\\'"); + zoneEl.innerHTML = + '' + (lastCard.name || zoneName) + ''; + // Always show count badge on top + if (cards.length > 0) { + zoneEl.innerHTML += '' + cards.length + ''; + } + } else { + // Fallback: icon + name + count (original look) + const icon = (zoneName === 'exile') ? '⬡' : '☠'; + const label = (zoneName === 'exile') ? 'Exile' : 'Grave'; + const countId = (zoneName === 'exile') ? 'exile-count-' : 'gv-count-'; + zoneEl.innerHTML = + '' + cards.length + '' + + '' + icon + '' + + '' + label + ''; + } +} + // ============================================================ // HAND ZONE RENDERING (thumbnails in zone.hand) // ============================================================ @@ -1055,6 +1089,8 @@ function initSetupScreen() { '' + '' + '' + + '' + + '' + '' + '' + @@ -1075,6 +1111,12 @@ function initSetupScreen() { '
' + '' + + '
' + + '' + + '' + + '' + + '
' + + '' + ''; diff --git a/ml/encoder/state_encoder.py b/ml/encoder/state_encoder.py index bc794cb..7056516 100644 --- a/ml/encoder/state_encoder.py +++ b/ml/encoder/state_encoder.py @@ -141,8 +141,17 @@ class CardEmbeddingIndex: logger.error("Auto-download failed: %s", e) return None - def get_embedding(self, card_name: str) -> np.ndarray: - """Get embedding vector for a card name. Returns zero vector if not found.""" + def get_embedding(self, card_name) -> np.ndarray: + """Get embedding vector for a card name. Returns zero vector if not found. + + Accepts either a plain string or a dict with a 'name' key (defensive + against the LAN client sending structured battlefield objects). + """ + # Handle dict objects from the LAN client's battlefield_detail + if isinstance(card_name, dict): + card_name = card_name.get('name', '') + if not card_name or not isinstance(card_name, str): + return self._zero_vec if not self._loaded: return self._zero_vec idx = self._name_to_idx.get(card_name.lower()) @@ -155,8 +164,11 @@ class CardEmbeddingIndex: return self._embeddings[idx] return self._zero_vec - def mean_pool_zone(self, card_names: List[str]) -> np.ndarray: - """Mean-pool embeddings for a list of card names (a game zone).""" + def mean_pool_zone(self, card_names: list) -> np.ndarray: + """Mean-pool embeddings for a list of card names (a game zone). + + Each entry may be a plain string or a dict with a 'name' key. + """ if not card_names: return self._zero_vec vectors = [self.get_embedding(name) for name in card_names] diff --git a/routes/deckbuilder.py b/routes/deckbuilder.py index 45f5477..6c8384a 100644 --- a/routes/deckbuilder.py +++ b/routes/deckbuilder.py @@ -245,6 +245,20 @@ async def add_deck_card(deck_id: int, req: AddDeckCardRequest): ).fetchone() card_name = ce_row["name"] if ce_row else (req.card_name or "") + # Ensure card_records has type_line for this card (Scryfall lookup if missing) + if card_name: + existing_cr = conn.execute( + "SELECT type_line FROM card_records WHERE name = ? LIMIT 1", (card_name,) + ).fetchone() + if not existing_cr or not existing_cr["type_line"]: + enriched = _enrich_from_scryfall(card_name) + if enriched and enriched.get("type_line"): + conn.execute( + "INSERT OR IGNORE INTO card_records (name, type_line, oracle_text, cmc) VALUES (?,?,?,?)", + (card_name, enriched["type_line"], enriched.get("oracle_text", ""), enriched.get("cmc", 0)) + ) + conn.commit() + existing = conn.execute( "SELECT id, quantity FROM deck_cards WHERE deck_id = ? AND scryfall_id = ?", (deck_id, req.scryfall_id)