Complete frontend rewrite from vanilla HTML/CSS/JS to React 19 + TypeScript + Tailwind CSS v4. Frontend (frontend/commander-ai-lab-ui/): - Vite + React 19 + TypeScript + Tailwind CSS v4 + React Router - 7 pages: BatchSim, Collection, Decks, AutoGen, Simulator, Coach, Training - Reusable components: Spinner, StatusBadge, CardImage, ManaSymbols, EmptyState - Full typed API client layer (8 modules matching all backend endpoints) - Recharts for mana curves, win rate charts, progress bars - Lucide icons throughout - Custom dark theme with MTG mana color tokens - Code splitting (recharts + react in separate chunks) Backend (lab_api.py): - SPA-aware static file serving with catch-all for client-side routing - Fallback to legacy ui/ folder if React dist not built - Updated startup log with SPA route list Build system: - .gitignore added (was missing - removes pyc, db, node_modules, etc.) - build-full-installer-v2.py: v23 with Node.js check + React build step - Vite build config with manual chunks for optimal loading Also includes: V3 deck generator fixes, coach improvements, DeepSeek sim modules
622 lines
25 KiB
JavaScript
622 lines
25 KiB
JavaScript
/**
|
|
* 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 (Perplexity 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.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;
|
|
|
|
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) {
|
|
toast('V3 Deck Generator not available — check Perplexity API key', 'error');
|
|
}
|
|
} 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());
|
|
}
|
|
|
|
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: [],
|
|
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 = '<div class="dg-dropdown-loading">Searching...</div>';
|
|
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 = '<div class="dg-dropdown-empty">Search failed</div>';
|
|
}
|
|
}
|
|
|
|
function renderDropdown(results) {
|
|
const dropdown = $('dg-commander-dropdown');
|
|
|
|
if (results.length === 0) {
|
|
dropdown.innerHTML = '<div class="dg-dropdown-empty">No legendary creatures found</div>';
|
|
return;
|
|
}
|
|
|
|
dropdown.innerHTML = results.map((r, idx) => {
|
|
const colors = (r.color_identity || [])
|
|
.map(c => COLOR_MAP[c] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
|
|
.join('');
|
|
|
|
const badge = r.in_collection
|
|
? '<span class="dg-dropdown-item-badge dg-badge-owned">Owned</span>'
|
|
: '<span class="dg-dropdown-item-badge dg-badge-scryfall">Scryfall</span>';
|
|
|
|
const imgSrc = r.image_url ? r.image_url.replace('version=normal', 'version=small') : '';
|
|
const imgTag = imgSrc
|
|
? '<img class="dg-dropdown-item-img" src="' + imgSrc + '" alt="" loading="lazy" />'
|
|
: '<div class="dg-dropdown-item-img" style="background:var(--lab-surface)"></div>';
|
|
|
|
return '<div class="dg-dropdown-item" data-idx="' + idx + '">'
|
|
+ imgTag
|
|
+ '<div class="dg-dropdown-item-info">'
|
|
+ ' <div class="dg-dropdown-item-name">' + escHtml(r.name) + '</div>'
|
|
+ ' <div class="dg-dropdown-item-type">' + escHtml(r.type_line || '') + '</div>'
|
|
+ '</div>'
|
|
+ '<div style="display:flex;gap:3px;align-items:center">' + colors + '</div>'
|
|
+ badge
|
|
+ '</div>';
|
|
}).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] ? '<span class="dg-color-pip ' + COLOR_MAP[c].cls + '">' + COLOR_MAP[c].label + '</span>' : '')
|
|
.join('');
|
|
|
|
const img = $('dg-commander-img');
|
|
if (cmdr.image_url) {
|
|
// Use normal size for readable oracle text; ensure we don't have small version
|
|
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;
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
// ── Generate Deck ───────────────────────────────────────
|
|
async function generateDeck() {
|
|
if (!state.commander) { toast('Select a commander first', 'error'); return; }
|
|
if (!state.v3Ready) { toast('V3 generator not ready — check Perplexity API key', 'error'); return; }
|
|
|
|
setLoading(true, 'Generating deck via Perplexity AI...');
|
|
|
|
const body = buildRequestBody();
|
|
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();
|
|
renderResults(state.previewResult);
|
|
|
|
const stats = state.previewResult.stats || {};
|
|
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
|
|
} catch (e) {
|
|
toast(e.message || 'Generation failed', '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);
|
|
}
|
|
|
|
function renderStrategyBar(result) {
|
|
const bar = $('dg-strategy-bar');
|
|
const bracket = result.bracket || {};
|
|
const archetype = result.archetype || '';
|
|
const strategy = result.strategy_summary || '';
|
|
|
|
bar.innerHTML = '<div class="dg-strategy-info">'
|
|
+ '<span class="dg-bracket-badge dg-bracket-' + (bracket.level || 0) + '">B' + (bracket.level || '?') + '</span>'
|
|
+ '<span class="dg-archetype-badge">' + escHtml(archetype) + '</span>'
|
|
+ '<span class="dg-strategy-text">' + escHtml(strategy) + '</span>'
|
|
+ '</div>'
|
|
+ (bracket.reasoning ? '<div class="dg-bracket-reasoning">' + escHtml(bracket.reasoning) + '</div>' : '')
|
|
+ (bracket.game_changers && bracket.game_changers.length
|
|
? '<div class="dg-game-changers">Game Changers: ' + bracket.game_changers.map(c => '<span class="dg-gc-chip">' + escHtml(c) + '</span>').join('') + '</div>'
|
|
: '');
|
|
}
|
|
|
|
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 '<div class="dg-stat-chip ' + cls + '">'
|
|
+ '<span class="dg-stat-num">' + num + '</span>'
|
|
+ '<span class="dg-stat-label">' + label + '</span>'
|
|
+ '</div>';
|
|
}
|
|
|
|
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 = '<div class="dg-sub-bar">'
|
|
+ '<span class="dg-sub-stat dg-sub-owned">' + sub.owned + ' owned</span>'
|
|
+ '<span class="dg-sub-stat dg-sub-substituted">' + sub.substituted + ' substituted</span>'
|
|
+ '<span class="dg-sub-stat dg-sub-missing">' + sub.missing + ' still missing</span>'
|
|
+ '</div>';
|
|
}
|
|
|
|
function renderCardGroups(result) {
|
|
const container = $('dg-card-groups');
|
|
const cards = result.cards || [];
|
|
const commander = result.commander;
|
|
|
|
// Group by category
|
|
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 = '';
|
|
|
|
// Commander section
|
|
if (commander) {
|
|
html += '<div class="dg-card-group">'
|
|
+ '<div class="dg-group-header">'
|
|
+ '<span class="dg-group-title">⚔ Commander</span>'
|
|
+ '<span class="dg-group-count">1</span>'
|
|
+ '</div>'
|
|
+ '<div class="dg-group-body">'
|
|
+ '<div class="dg-card-row dg-card-row-commander">'
|
|
+ '<span class="dg-card-row-name" style="font-weight:600;color:#fff">' + escHtml(commander.name) + '</span>'
|
|
+ '<span class="dg-card-row-source dg-src-collection">Commander</span>'
|
|
+ '</div>'
|
|
+ '</div></div>';
|
|
}
|
|
|
|
// Card groups
|
|
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 += '<div class="dg-card-group">'
|
|
+ '<div class="dg-group-header" data-type="' + type + '">'
|
|
+ '<span class="dg-group-title">' + icon + ' ' + escHtml(type) + '</span>'
|
|
+ '<span class="dg-group-count">' + groupCards.length + '</span>'
|
|
+ '</div>'
|
|
+ '<div class="dg-group-body" data-group="' + type + '">';
|
|
|
|
for (const card of groupCards) {
|
|
html += renderCardRow(card);
|
|
}
|
|
|
|
html += '</div></div>';
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
|
|
// Collapse toggles
|
|
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 = '<span class="dg-card-status ' + statusInfo.cls + '">' + statusInfo.label + '</span>';
|
|
|
|
const roleTags = (card.role_tags || []).slice(0, 3)
|
|
.map(r => '<span class="dg-role-chip">' + escHtml(r) + '</span>')
|
|
.join('');
|
|
|
|
// Substitution indicator
|
|
let subHtml = '';
|
|
if (card.status === 'substituted' && card.selected_substitute) {
|
|
subHtml = '<div class="dg-card-sub-line">'
|
|
+ '<span class="dg-sub-arrow">→</span> '
|
|
+ '<span class="dg-sub-name">' + escHtml(card.selected_substitute) + '</span>'
|
|
+ '</div>';
|
|
} else if (card.status === 'missing' && card.alternatives && card.alternatives.length > 0) {
|
|
subHtml = '<div class="dg-card-sub-line dg-sub-suggestions">'
|
|
+ '<span class="dg-sub-label">Alternatives:</span> '
|
|
+ card.alternatives.slice(0, 3).map(a =>
|
|
'<span class="dg-alt-chip" title="' + escAttr(a.reason || '') + '">'
|
|
+ escHtml(a.name) + ' (' + (a.similarity_score * 100).toFixed(0) + '%)'
|
|
+ '</span>'
|
|
).join('')
|
|
+ '</div>';
|
|
}
|
|
|
|
const price = card.estimated_price_usd
|
|
? '<span class="dg-card-price">$' + card.estimated_price_usd.toFixed(2) + '</span>'
|
|
: '';
|
|
|
|
return '<div class="dg-card-row dg-card-status-' + card.status + '">'
|
|
+ '<div class="dg-card-row-main">'
|
|
+ '<span class="dg-card-row-name">' + escHtml(card.name) + '</span>'
|
|
+ '<span class="dg-card-row-roles">' + roleTags + '</span>'
|
|
+ statusBadge
|
|
+ price
|
|
+ '</div>'
|
|
+ (card.reason ? '<div class="dg-card-reason">' + escHtml(card.reason) + '</div>' : '')
|
|
+ subHtml
|
|
+ '</div>';
|
|
}
|
|
|
|
// ── 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 = '<span class="dg-btn-icon">💾</span> 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;
|
|
}
|
|
|
|
// Build and copy text
|
|
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, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
return { init };
|
|
})();
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
DeckGenerator.init();
|
|
});
|