/**
* 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.location.origin;
const LS_PREFS_KEY = 'coll_preferences';
const DEBOUNCE_MS = 300;
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,
preferences: {
pageSize: 50,
sortField: 'name',
sortDir: 'asc',
visibleColumns: ['quantity','name','mana_cost','type_line','color_identity','cmc','power_toughness','rarity','set_name','tcg_price','edhrec_rank','salt_score','category','finish'],
},
};
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();
}
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();
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
${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}
`;
}
// ── 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: false },
{ key: 'finish', label: 'Finish', sortable: true },
{ key: 'oracle_text', label: 'Oracle', sortable: false },
];
function renderTableHeaderHtml() {
const headers = COLUMNS.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('');
}
// ── 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 = `
${card.imageUrl
? `
})
`
: `
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, '
')}
`
: ''}
Finish
Categories
${catChips}
${catSuggestions}
Tags
${tags.map(tag =>
`${escapeHtml(tag)}
`
).join('')}
`;
}
function renderEdhrecTab() {
if (state.edhrecLoading) {
return ``;
}
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 ? `
${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 ? `${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 = `
${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 = `
${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
? `
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 `
|
|
`;
}).join('');
return `
Map CSV columns to collection fields. Pre-filled for ${escapeHtml(state.importSource)} format.
| CSV Column | Maps To |
${rows}
`;
}
function renderImportStep4() {
return `
${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 = ``;
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
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 = `
`;
}
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';
}
// ── 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 = `
${content}
`;
}
function renderScanUpload() {
const preview = scanState.previewUrl
? '
'
: '';
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
'}
`;
}
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 ``;
}
const imgHtml = r.image_uri
? '
'
: 'No image
';
return ``;
}).join('');
// Manual cards added via name lookup
const manualRows = (scanState.manualCards || []).map((mc, mi) => {
return `
${mc.image_uri
? '
 + ')
'
: '
No image
'}
${escapeHtml(mc.name)}
Manual
${mc.set_code ? ' · ' + escapeHtml(mc.set_code.toUpperCase()) : ''}
`;
}).join('');
return `
`;
}
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();
loadCollection();
}, 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,
};
})();