// SETTINGS MODAL
// =============================================================================
/**
* Opens the unified Settings modal, optionally navigating to a section.
* @param {string} [section='site'] - Section to display: 'site', 'system', 'table', 'grouping', 'api', 'cloud', 'images', 'storage', 'goldback', 'changelog'
*/
const showSettingsModal = (section = 'site') => {
const modal = document.getElementById('settingsModal');
if (!modal) return;
syncSettingsUI();
switchSettingsSection(section);
if (window.openModalById) {
openModalById('settingsModal');
} else {
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
};
/**
* Closes the Settings modal.
*/
const hideSettingsModal = () => {
if (window.closeModalById) {
closeModalById('settingsModal');
} else {
const modal = document.getElementById('settingsModal');
if (modal) modal.style.display = 'none';
try { document.body.style.overflow = ''; } catch (e) { /* ignore */ }
}
};
/**
* Switches the visible section panel in the Settings modal.
* @param {string} name - Section key: 'site', 'system', 'table', 'grouping', 'api', 'cloud', 'images', 'storage', 'goldback', 'changelog'
*/
const switchSettingsSection = (name) => {
const targetName = document.getElementById(`settingsPanel_${name}`) ? name : 'system';
// Hide all panels
document.querySelectorAll('.settings-section-panel').forEach(panel => {
panel.style.display = 'none';
});
// Show target panel
const target = document.getElementById(`settingsPanel_${targetName}`);
if (target) target.style.display = 'block';
// Update active nav item
document.querySelectorAll('.settings-nav-item').forEach(item => {
item.classList.toggle('active', item.dataset.section === targetName);
});
// Populate API data when switching to API section
if (targetName === 'api' && typeof populateApiSection === 'function') {
populateApiSection();
}
// Sync cloud UI when switching to Cloud section
if (targetName === 'cloud' && typeof syncCloudUI === 'function') {
syncCloudUI();
}
// Populate Images data and sync toggles when switching to Images section (STACK-96)
if (targetName === 'images') {
syncChipToggle('tableImagesToggle', localStorage.getItem('tableImagesEnabled') !== 'false');
syncChipToggle('numistaOverrideToggle', localStorage.getItem('numistaOverridePersonal') === 'true');
const sidesSync = safeGetElement('tableImageSidesToggle');
if (sidesSync) {
const curSides = localStorage.getItem('tableImageSides') || 'both';
sidesSync.querySelectorAll('.chip-sort-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.val === curSides));
}
populateImagesSection();
}
// Render the active log sub-tab when switching to the changelog section
if (targetName === 'changelog') {
const activeTab = document.querySelector('.settings-log-tab.active');
const activeKey = activeTab ? activeTab.dataset.logTab : 'changelog';
switchLogTab(activeKey);
}
// Populate Storage section when switching to it
if (targetName === 'storage' && typeof renderStorageSection === 'function') {
renderStorageSection();
}
};
/**
* Switches the visible provider tab in the API section.
* @param {string} key - Provider key: 'NUMISTA', 'METALS_DEV', 'METALS_API', 'METAL_PRICE_API', 'CUSTOM'
*/
const switchProviderTab = (key) => {
// Hide all provider panels
document.querySelectorAll('.settings-provider-panel').forEach(panel => {
panel.style.display = 'none';
});
// Show target panel
const target = document.getElementById(`providerPanel_${key}`);
if (target) target.style.display = 'block';
// Update active tab
document.querySelectorAll('.settings-provider-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.provider === key);
});
// Render Numista bulk sync UI when switching to Numista tab (STACK-87/88)
if (key === 'NUMISTA' && typeof renderNumistaSyncUI === 'function') {
const syncGroup = document.getElementById('numistaBulkSyncGroup');
if (syncGroup && syncGroup.style.display !== 'none') {
renderNumistaSyncUI();
}
}
};
/**
* Switches the visible log sub-tab in the Activity Log panel.
* Re-renders the tab content on every switch to ensure fresh data.
* @param {string} key - Sub-tab key: 'changelog', 'metals', 'catalogs', 'pricehistory'
*/
const switchLogTab = (key) => {
// Hide all log panels
document.querySelectorAll('.settings-log-panel').forEach(panel => {
panel.style.display = 'none';
});
// Show target panel
const target = document.getElementById(`logPanel_${key}`);
if (target) target.style.display = 'block';
// Update active tab
document.querySelectorAll('.settings-log-tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.logTab === key);
});
// Always re-render to show fresh data
renderLogTab(key);
};
/** Dispatch map: log sub-tab key → window function name */
const LOG_TAB_RENDERERS = {
changelog: 'renderChangeLog',
metals: 'renderSpotHistoryTable',
lbma: 'renderLbmaHistoryTable',
catalogs: 'renderCatalogHistoryForSettings',
pricehistory: 'renderItemPriceHistoryTable',
cloud: 'renderCloudActivityTable',
};
/**
* Dispatches to the appropriate render function for a log sub-tab.
* @param {string} key - Sub-tab key
*/
const renderLogTab = (key) => {
const fn = window[LOG_TAB_RENDERERS[key]];
if (typeof fn === 'function') fn();
};
/**
* Syncs all Settings UI controls with current application state.
* Called each time the modal opens to ensure controls reflect live values.
*/
const syncSettingsUI = () => {
// Theme picker
const currentTheme = localStorage.getItem(THEME_KEY) || 'light';
document.querySelectorAll('.theme-option').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === currentTheme);
});
// Items per page
const ippSelect = safeGetElement('settingsItemsPerPage');
if (ippSelect) {
ippSelect.value = itemsPerPage === Infinity ? 'all' : String(itemsPerPage);
}
// Chip min count — sync with inline control
const chipMinSetting = document.getElementById('settingsChipMinCount');
const chipMinInline = document.getElementById('chipMinCount');
if (chipMinSetting) {
chipMinSetting.value = localStorage.getItem('chipMinCount') || '3';
}
// Smart name grouping — sync with inline toggle
const groupSetting = document.getElementById('settingsGroupNameChips');
if (groupSetting && window.featureFlags) {
const gVal = featureFlags.isEnabled('GROUPED_NAME_CHIPS') ? 'yes' : 'no';
groupSetting.querySelectorAll('.chip-sort-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.val === gVal);
});
}
// Dynamic name chips — sync toggle with feature flag
const dynamicSetting = document.getElementById('settingsDynamicChips');
if (dynamicSetting && window.featureFlags) {
const dVal = featureFlags.isEnabled('DYNAMIC_NAME_CHIPS') ? 'yes' : 'no';
dynamicSetting.querySelectorAll('.chip-sort-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.val === dVal);
});
}
// Chip quantity badge — sync toggle with feature flag
const qtyBadgeSetting = document.getElementById('settingsChipQtyBadge');
if (qtyBadgeSetting && window.featureFlags) {
const qVal = featureFlags.isEnabled('CHIP_QTY_BADGE') ? 'yes' : 'no';
qtyBadgeSetting.querySelectorAll('.chip-sort-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.val === qVal);
});
}
// Fuzzy autocomplete — sync toggle with feature flag
const autocompleteSetting = document.getElementById('settingsFuzzyAutocomplete');
if (autocompleteSetting && window.featureFlags) {
const aVal = featureFlags.isEnabled('FUZZY_AUTOCOMPLETE') ? 'yes' : 'no';
autocompleteSetting.querySelectorAll('.chip-sort-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.val === aVal);
});
}
// Numista name matching — sync toggle with feature flag
const numistaLookupSetting = document.getElementById('settingsNumistaLookup');
if (numistaLookupSetting && window.featureFlags) {
const nlVal = featureFlags.isEnabled('NUMISTA_SEARCH_LOOKUP') ? 'yes' : 'no';
numistaLookupSetting.querySelectorAll('.chip-sort-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.val === nlVal);
});
}
// Numista lookup rule tables
renderSeedRuleTable();
renderCustomRuleTable();
// Chip grouping tables and dropdown
if (typeof window.populateBlacklistDropdown === 'function') window.populateBlacklistDropdown();
if (typeof window.renderBlacklistTable === 'function') window.renderBlacklistTable();
if (typeof window.renderCustomGroupTable === 'function') window.renderCustomGroupTable();
// Inline chip config table
renderInlineChipConfigTable();
// Filter chip category config table
renderFilterChipCategoryTable();
// Chip sort order — sync settings toggle with stored value
const chipSortSetting = document.getElementById('settingsChipSortOrder');
if (chipSortSetting) {
const saved = localStorage.getItem('chipSortOrder');
const active = (saved === 'count') ? 'count' : 'alpha';
chipSortSetting.querySelectorAll('.chip-sort-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.sort === active);
});
}
// Storage footer
updateSettingsFooter();
// API status
if (typeof renderApiStatusSummary === 'function') {
renderApiStatusSummary();
}
// Numista usage bar
if (typeof renderNumistaUsageBar === 'function') {
renderNumistaUsageBar();
}
// PCGS usage bar
if (typeof renderPcgsUsageBar === 'function') {
renderPcgsUsageBar();
}
// Display currency (STACK-50)
if (typeof syncCurrencySettingsUI === 'function') {
syncCurrencySettingsUI();
}
// Goldback denomination pricing (STACK-45)
if (typeof syncGoldbackSettingsUI === 'function') {
syncGoldbackSettingsUI();
}
// Numista bulk sync visibility (STACK-87/88)
const numistaSyncGroup = document.getElementById('numistaBulkSyncGroup');
if (numistaSyncGroup) {
const showBulkSync = window.featureFlags?.isEnabled('COIN_IMAGES') &&
window.imageCache?.isAvailable();
numistaSyncGroup.style.display = showBulkSync ? '' : 'none';
}
// Card style (STAK-118)
const cardStyleSelect = document.getElementById('settingsCardStyle');
if (cardStyleSelect) {
cardStyleSelect.value = localStorage.getItem(CARD_STYLE_KEY) || 'A';
}
// Desktop card view toggle (STAK-118)
const desktopCardToggle = document.getElementById('settingsDesktopCardView');
if (desktopCardToggle) {
const dcVal = localStorage.getItem(DESKTOP_CARD_VIEW_KEY) === 'true' ? 'yes' : 'no';
desktopCardToggle.querySelectorAll('.chip-sort-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.val === dcVal);
});
}
// Display timezone (STACK-63)
const tzSelect = document.getElementById('settingsTimezone');
if (tzSelect) {
tzSelect.value = localStorage.getItem(TIMEZONE_KEY) || 'auto';
}
// Spot compare mode (STACK-92)
const spotCompareSelect = document.getElementById('settingsSpotCompareMode');
if (spotCompareSelect) {
spotCompareSelect.value = localStorage.getItem(SPOT_COMPARE_MODE_KEY) || 'close-close';
}
// Header shortcuts (STACK-54)
syncHeaderToggleUI();
// Layout visibility (STACK-54)
syncLayoutVisibilityUI();
// Set first provider tab active if none visible — default to Numista
const anyVisible = document.querySelector('.settings-provider-panel[style*="display: block"]');
if (!anyVisible) {
switchProviderTab('NUMISTA');
}
};
/**
* Updates the storage + version footer bar at the bottom of the Settings modal.
*/
const updateSettingsFooter = async () => {
const footerEl = document.getElementById('settingsFooter');
if (!footerEl) return;
let storageText = '';
try {
let totalBytes = 0;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const val = localStorage.getItem(key);
totalBytes += (key.length + (val ? val.length : 0)) * 2; // UTF-16
}
const lsMb = (totalBytes / (1024 * 1024)).toFixed(2);
storageText = `LS: ${lsMb} MB / 5 MB`;
// Append IndexedDB usage if available
if (window.imageCache?.isAvailable()) {
try {
const idbUsage = await imageCache.getStorageUsage();
const idbMb = (idbUsage.totalBytes / (1024 * 1024)).toFixed(2);
const idbLimit = (idbUsage.limitBytes / (1024 * 1024)).toFixed(0);
storageText += ` \u00b7 IDB: ${idbMb} MB / ${idbLimit} MB`;
} catch { /* ignore */ }
}
} catch (e) {
storageText = 'Storage: unknown';
}
footerEl.textContent = `${storageText} \u00b7 v${APP_VERSION}`;
};
/**
* Wires a yes/no chip toggle to a feature flag.
* Handles click delegation, flag enable/disable, active-class sync,
* optional mirror element sync, and optional callback.
*
* @param {string} elementId - DOM id of the toggle container
* @param {string} flagName - Feature flag key (e.g. 'GROUPED_NAME_CHIPS')
* @param {Object} [opts]
* @param {string} [opts.syncId] - DOM id of a mirror toggle to keep in sync
* @param {Function} [opts.onApply] - Called after toggle with (isEnabled) arg
*/
const wireFeatureFlagToggle = (elementId, flagName, opts = {}) => {
const el = document.getElementById(elementId);
if (!el) return;
el.addEventListener('click', (e) => {
const btn = e.target.closest('.chip-sort-btn');
if (!btn) return;
const isEnabled = btn.dataset.val === 'yes';
if (window.featureFlags) {
if (isEnabled) featureFlags.enable(flagName);
else featureFlags.disable(flagName);
}
el.querySelectorAll('.chip-sort-btn').forEach(b => {
b.classList.toggle('active', b.dataset.val === btn.dataset.val);
});
if (opts.syncId) {
const syncEl = document.getElementById(opts.syncId);
if (syncEl) syncEl.querySelectorAll('.chip-sort-btn').forEach(b => {
b.classList.toggle('active', b.dataset.val === btn.dataset.val);
});
}
if (opts.onApply) opts.onApply(isEnabled);
});
};
window.wireFeatureFlagToggle = wireFeatureFlagToggle;
/**
* Syncs a chip-sort-toggle's active state from a boolean value.
* @param {string} elementId - DOM id of the .chip-sort-toggle container
* @param {boolean} isOn - Whether the 'yes' button should be active
*/
const syncChipToggle = (elementId, isOn) => {
const el = document.getElementById(elementId);
if (!el) return;
el.querySelectorAll('.chip-sort-btn').forEach(btn => {
const btnIsYes = btn.dataset.val === 'yes';
btn.classList.toggle('active', isOn ? btnIsYes : !btnIsYes);
});
};
/**
* Wires a yes/no chip toggle to a raw localStorage key (not a feature flag).
* Handles click delegation, localStorage read/write, active-class sync, and optional callback.
*
* @param {string} elementId - DOM id of the toggle container
* @param {string} storageKey - localStorage key to read/write ('true'/'false')
* @param {Object} [opts]
* @param {boolean} [opts.defaultVal=false] - Default value when no localStorage entry exists
* @param {Function} [opts.onApply] - Called after toggle with (isEnabled) arg
*/
const wireStorageToggle = (elementId, storageKey, opts = {}) => {
const el = document.getElementById(elementId);
if (!el) return;
// Set initial state
const defaultVal = opts.defaultVal ?? false;
const stored = localStorage.getItem(storageKey);
const isOn = stored !== null ? stored === 'true' : defaultVal;
syncChipToggle(elementId, isOn);
el.addEventListener('click', (e) => {
const btn = e.target.closest('.chip-sort-btn');
if (!btn) return;
const isEnabled = btn.dataset.val === 'yes';
localStorage.setItem(storageKey, isEnabled ? 'true' : 'false');
syncChipToggle(elementId, isEnabled);
if (opts.onApply) opts.onApply(isEnabled);
});
};
window.wireStorageToggle = wireStorageToggle;
window.syncChipToggle = syncChipToggle;
/**
* Wires a chip sort order toggle (alpha/count) with bidirectional sync.
*
* @param {string} elementId - DOM id of the toggle container
* @param {string} [syncId] - DOM id of a mirror toggle to keep in sync
*/
const wireChipSortToggle = (elementId, syncId) => {
const el = document.getElementById(elementId);
if (!el) return;
el.addEventListener('click', (e) => {
const btn = e.target.closest('.chip-sort-btn');
if (!btn) return;
const val = btn.dataset.sort;
localStorage.setItem('chipSortOrder', val);
el.querySelectorAll('.chip-sort-btn').forEach(b => {
b.classList.toggle('active', b.dataset.sort === val);
});
if (syncId) {
const syncEl = document.getElementById(syncId);
if (syncEl) syncEl.querySelectorAll('.chip-sort-btn').forEach(b => {
b.classList.toggle('active', b.dataset.sort === val);
});
}
if (typeof renderActiveFilters === 'function') renderActiveFilters();
});
};
window.wireChipSortToggle = wireChipSortToggle;
// STAK-135:
// setupSettingsEventListeners() moved to js/settings-listeners.js to keep
// listener wiring split by settings tab/concern.
/**
* One-time migration from legacy apiProviderOrder + syncMode to priority numbers (STACK-90).
* Maps first "always" provider → 1, remaining providers → 2,3 in order, disabled → 0.
* @returns {Object} Priority map { METALS_DEV: 1, METALS_API: 2, ... }
*/
const migrateProviderPriority = () => {
const priorities = {};
const metalsProviders = Object.keys(API_PROVIDERS);
let order;
try {
const stored = localStorage.getItem('apiProviderOrder');
order = stored ? JSON.parse(stored) : null;
} catch (e) { /* ignore */ }
if (!Array.isArray(order) || order.length === 0) {
order = metalsProviders;
}
// Read legacy sync modes
let syncModes = {};
try {
const cfg = loadApiConfig();
syncModes = cfg.syncMode || {};
} catch (e) { /* ignore */ }
let nextPriority = 1;
// First pass: assign based on legacy order + sync mode
order.forEach(prov => {
if (!metalsProviders.includes(prov)) return;
const mode = syncModes[prov] || 'always';
if (mode === 'backup' && nextPriority === 1) {
// All backup = assign sequentially starting at 2
priorities[prov] = nextPriority++;
} else {
priorities[prov] = nextPriority++;
}
});
// Ensure any providers not in legacy order get a priority
metalsProviders.forEach(prov => {
if (!(prov in priorities)) {
priorities[prov] = nextPriority++;
}
});
// Ensure STAKTRAKR is always rank 1 for fresh migrations
if (priorities.STAKTRAKR && priorities.STAKTRAKR !== 1) {
const currentRank1 = Object.entries(priorities).find(([, p]) => p === 1);
if (currentRank1) priorities[currentRank1[0]] = priorities.STAKTRAKR;
priorities.STAKTRAKR = 1;
}
saveProviderPriorities(priorities);
return priorities;
};
/**
* Loads provider priority map from localStorage.
* Falls back to migration if not found.
* @returns {Object} Priority map { METALS_DEV: 1, METALS_API: 2, ... }
*/
const loadProviderPriorities = () => {
try {
const stored = localStorage.getItem('providerPriority');
if (stored) {
const priorities = JSON.parse(stored);
if (typeof priorities === 'object' && priorities !== null) {
// Inject STAKTRAKR at rank 1 for existing users upgrading
if (!('STAKTRAKR' in priorities)) {
Object.keys(priorities).forEach(prov => {
if (priorities[prov] > 0) priorities[prov]++;
});
priorities.STAKTRAKR = 1;
saveProviderPriorities(priorities);
}
return priorities;
}
}
} catch (e) { /* ignore */ }
return migrateProviderPriority();
};
/**
* Saves provider priority map and writes backward-compatible apiProviderOrder (STACK-90).
* @param {Object} priorities - { METALS_DEV: 1, METALS_API: 2, ... }
*/
const saveProviderPriorities = (priorities) => {
try {
localStorage.setItem('providerPriority', JSON.stringify(priorities));
// Backward compatibility: write apiProviderOrder sorted by priority (non-disabled only)
const sorted = Object.entries(priorities)
.filter(([, p]) => p > 0)
.sort((a, b) => a[1] - b[1])
.map(([prov]) => prov);
localStorage.setItem('apiProviderOrder', JSON.stringify(sorted));
} catch (e) { /* ignore */ }
};
/**
* Sets all priority <select> values from a priority map.
* @param {Object} priorities - { METALS_DEV: 1, ... }
*/
const syncProviderPriorityUI = (priorities) => {
Object.entries(priorities).forEach(([prov, val]) => {
const sel = document.getElementById(`providerPriority_${prov}`);
if (sel) sel.value = String(val);
});
};
/**
* Sets up change listeners on provider priority selects (STACK-90).
* Auto-shifts: setting provider X to priority N bumps any existing N holder to N+1 (cascade).
*/
const setupProviderPriority = () => {
const selects = document.querySelectorAll('.provider-priority-select');
if (!selects.length) return;
selects.forEach(sel => {
sel.addEventListener('change', () => {
const provider = sel.dataset.provider;
const newVal = parseInt(sel.value, 10);
const priorities = loadProviderPriorities();
if (newVal === 0) {
// Disabled — just set it
priorities[provider] = 0;
} else {
// Auto-shift: bump any provider already at this priority
const oldVal = priorities[provider];
Object.keys(priorities).forEach(prov => {
if (prov !== provider && priorities[prov] === newVal && priorities[prov] > 0) {
// Cascade: shift this one to the old slot (or next available)
priorities[prov] = oldVal > 0 ? oldVal : newVal + 1;
}
});
priorities[provider] = newVal;
}
saveProviderPriorities(priorities);
syncProviderPriorityUI(priorities);
if (typeof autoSelectDefaultProvider === 'function') {
autoSelectDefaultProvider();
}
});
});
};
/**
* Renders the inline chip config table in Settings > Grouping.
* Delegates to the generic _renderSectionConfigTable helper.
*/
const renderInlineChipConfigTable = () => _renderSectionConfigTable({
containerId: 'inlineChipConfigContainer',
getConfig: getInlineChipConfig,
saveConfig: saveInlineChipConfig,
emptyText: 'No chip types available',
onApply: typeof renderTable === 'function' ? renderTable : null,
onRender: () => renderInlineChipConfigTable(),
});
/**
* Renders the filter chip category config table in Settings > Chips.
* Each row has a checkbox (enable/disable) and up/down arrows for reordering.
*/
const renderFilterChipCategoryTable = () => {
const container = document.getElementById('filterChipCategoryContainer');
if (!container || typeof getFilterChipCategoryConfig !== 'function') return;
const config = getFilterChipCategoryConfig();
container.textContent = '';
if (!config.length) {
const empty = document.createElement('div');
empty.className = 'chip-grouping-empty';
empty.textContent = 'No chip categories available';
container.appendChild(empty);
return;
}
const table = document.createElement('table');
table.className = 'chip-grouping-table';
// Header row
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
['', 'Category', 'Group', ''].forEach(text => {
const th = document.createElement('th');
th.textContent = text;
th.style.cssText = 'font-size:0.75rem;font-weight:normal;opacity:0.6;padding:0.2rem 0.4rem';
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
config.forEach((cat, idx) => {
const tr = document.createElement('tr');
tr.dataset.catId = cat.id;
// Checkbox cell
const tdCheck = document.createElement('td');
tdCheck.style.cssText = 'width:2rem;text-align:center';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = cat.enabled;
cb.className = 'filter-cat-toggle';
cb.title = 'Toggle ' + cat.label;
cb.addEventListener('change', () => {
const cfg = getFilterChipCategoryConfig();
const item = cfg.at(idx);
if (item) {
item.enabled = cb.checked;
saveFilterChipCategoryConfig(cfg);
if (typeof renderActiveFilters === 'function') renderActiveFilters();
}
});
tdCheck.appendChild(cb);
// Label cell
const tdLabel = document.createElement('td');
tdLabel.textContent = cat.label;
// Group dropdown cell
const tdGroup = document.createElement('td');
tdGroup.style.cssText = 'width:3rem;text-align:center';
const groupSelect = document.createElement('select');
groupSelect.className = 'control-select';
groupSelect.title = 'Merge group — same letter = chips sort together';
groupSelect.style.cssText = 'width:auto;min-width:3.2rem;padding:0.15rem 0.3rem;font-size:0.8rem';
const groupOptions = ['\u2014', 'A', 'B', 'C', 'D', 'E'];
groupOptions.forEach(letter => {
const opt = document.createElement('option');
opt.value = letter === '\u2014' ? '' : letter;
opt.textContent = letter;
if ((cat.group || '') === opt.value) opt.selected = true;
groupSelect.appendChild(opt);
});
groupSelect.addEventListener('change', () => {
const cfg = getFilterChipCategoryConfig();
const item = cfg.at(idx);
if (item) {
item.group = groupSelect.value || null;
saveFilterChipCategoryConfig(cfg);
renderFilterChipCategoryTable();
if (typeof renderActiveFilters === 'function') renderActiveFilters();
}
});
tdGroup.appendChild(groupSelect);
// Arrow buttons cell
const tdMove = document.createElement('td');
tdMove.style.cssText = 'width:3.5rem;text-align:right;white-space:nowrap';
const makeBtn = (dir, disabled) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'inline-chip-move';
btn.textContent = dir === 'up' ? '\u2191' : '\u2193';
btn.title = 'Move ' + dir;
btn.disabled = disabled;
btn.addEventListener('click', () => {
const cfg = getFilterChipCategoryConfig();
const j = dir === 'up' ? idx - 1 : idx + 1;
if (j < 0 || j >= cfg.length) return;
const moved = cfg.splice(idx, 1).at(0);
cfg.splice(j, 0, moved);
saveFilterChipCategoryConfig(cfg);
renderFilterChipCategoryTable();
if (typeof renderActiveFilters === 'function') renderActiveFilters();
});
return btn;
};
tdMove.appendChild(makeBtn('up', idx === 0));
tdMove.appendChild(makeBtn('down', idx === config.length - 1));
tr.append(tdCheck, tdLabel, tdGroup, tdMove);
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
};
/**
* Renders the built-in (seed) Numista lookup rules table with enable/disable toggles.
*/
const renderSeedRuleTable = () => {
const container = document.getElementById('seedRuleTableContainer');
if (!container || !window.NumistaLookup) return;
const rules = NumistaLookup.listSeedRules();
const enabledCount = typeof NumistaLookup.getEnabledSeedRuleCount === 'function'
? NumistaLookup.getEnabledSeedRuleCount() : rules.length;
const countBadge = document.getElementById('seedRuleCount');
if (countBadge) countBadge.textContent = `(${enabledCount}/${rules.length})`;
container.textContent = '';
if (!rules.length) {
const empty = document.createElement('div');
empty.className = 'chip-grouping-empty';
empty.textContent = 'No built-in patterns';
container.appendChild(empty);
return;
}
// Bulk toggle buttons
const btnBar = document.createElement('div');
btnBar.style.cssText = 'display:flex;gap:0.5rem;margin-bottom:0.5rem';
const enableAllBtn = document.createElement('button');
enableAllBtn.type = 'button';
enableAllBtn.className = 'btn';
enableAllBtn.textContent = 'Enable All';
enableAllBtn.style.cssText = 'font-size:0.75rem;padding:0.2rem 0.6rem';
enableAllBtn.addEventListener('click', () => {
NumistaLookup.setAllSeedRulesEnabled(true);
renderSeedRuleTable();
});
const disableAllBtn = document.createElement('button');
disableAllBtn.type = 'button';
disableAllBtn.className = 'btn';
disableAllBtn.textContent = 'Disable All';
disableAllBtn.style.cssText = 'font-size:0.75rem;padding:0.2rem 0.6rem';
disableAllBtn.addEventListener('click', () => {
NumistaLookup.setAllSeedRulesEnabled(false);
renderSeedRuleTable();
});
btnBar.append(enableAllBtn, disableAllBtn);
container.appendChild(btnBar);
const table = document.createElement('table');
table.className = 'chip-grouping-table';
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
['Enabled', 'Pattern', 'Numista Query', 'N#'].forEach(text => {
const th = document.createElement('th');
th.textContent = text;
th.style.cssText = 'font-size:0.75rem;font-weight:normal;opacity:0.6;padding:0.2rem 0.4rem';
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const rule of rules) {
const tr = document.createElement('tr');
// Enabled checkbox
const tdEnabled = document.createElement('td');
tdEnabled.style.cssText = 'width:2.5rem;text-align:center';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = typeof NumistaLookup.isSeedRuleEnabled === 'function'
? NumistaLookup.isSeedRuleEnabled(rule.id) : true;
cb.title = 'Toggle ' + rule.id;
cb.addEventListener('change', () => {
if (typeof NumistaLookup.setSeedRuleEnabled === 'function') {
NumistaLookup.setSeedRuleEnabled(rule.id, cb.checked);
}
// Update count badge
const newCount = typeof NumistaLookup.getEnabledSeedRuleCount === 'function'
? NumistaLookup.getEnabledSeedRuleCount() : rules.length;
if (countBadge) countBadge.textContent = `(${newCount}/${rules.length})`;
});
tdEnabled.appendChild(cb);
const tdPattern = document.createElement('td');
tdPattern.style.cssText = 'font-family:monospace;font-size:0.8rem;word-break:break-all';
tdPattern.textContent = rule.pattern;
const tdReplacement = document.createElement('td');
tdReplacement.textContent = rule.replacement;
const tdId = document.createElement('td');
tdId.style.cssText = 'font-size:0.85rem;opacity:0.7;white-space:nowrap';
tdId.textContent = rule.numistaId || '\u2014';
tr.append(tdEnabled, tdPattern, tdReplacement, tdId);
tbody.appendChild(tr);
}
table.appendChild(tbody);
container.appendChild(table);
};
/**
* Renders the custom Numista lookup rules table with delete buttons.
*/
const renderCustomRuleTable = () => {
const container = document.getElementById('customRuleTableContainer');
if (!container || !window.NumistaLookup) return;
const rules = NumistaLookup.listCustomRules();
container.textContent = '';
if (!rules.length) {
const empty = document.createElement('div');
empty.className = 'chip-grouping-empty';
empty.textContent = 'No custom patterns';
container.appendChild(empty);
return;
}
const table = document.createElement('table');
table.className = 'chip-grouping-table';
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
['Pattern', 'Numista Query', 'N#', ''].forEach(text => {
const th = document.createElement('th');
th.textContent = text;
th.style.cssText = 'font-size:0.75rem;font-weight:normal;opacity:0.6;padding:0.2rem 0.4rem';
headRow.appendChild(th);
});
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const rule of rules) {
const tr = document.createElement('tr');
const tdPattern = document.createElement('td');
tdPattern.style.cssText = 'font-family:monospace;font-size:0.8rem;word-break:break-all';
tdPattern.textContent = rule.pattern;
const tdReplacement = document.createElement('td');
tdReplacement.textContent = rule.replacement;
const tdId = document.createElement('td');
tdId.style.cssText = 'font-size:0.85rem;opacity:0.7;white-space:nowrap';
tdId.textContent = rule.numistaId || '\u2014';
const tdDelete = document.createElement('td');
tdDelete.style.cssText = 'width:2rem;text-align:center';
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'inline-chip-move';
delBtn.textContent = '\u2715';
delBtn.title = 'Delete rule';
delBtn.addEventListener('click', () => {
NumistaLookup.removeRule(rule.id);
renderCustomRuleTable();
});
tdDelete.appendChild(delBtn);
tr.append(tdPattern, tdReplacement, tdId, tdDelete);
tbody.appendChild(tr);
}
table.appendChild(tbody);
container.appendChild(table);
};
/**
* Syncs the display currency dropdown with current state (STACK-50).
* Populates options from SUPPORTED_CURRENCIES on first call.
*/
const syncCurrencySettingsUI = () => {
const sel = document.getElementById('settingsDisplayCurrency');
if (!sel) return;
if (sel.options.length === 0) {
SUPPORTED_CURRENCIES.forEach(c => {
const opt = document.createElement('option');
opt.value = c.code;
opt.textContent = `${c.code} \u2014 ${c.name}`;
sel.appendChild(opt);
});
}
sel.value = displayCurrency;
};
/**
* Syncs the Goldback settings panel UI with current state.
* Renders denomination price rows and updates enabled toggle.
*/
const syncGoldbackSettingsUI = () => {
// Toggle — Goldback pricing enabled
const toggleGroup = document.getElementById('settingsGoldbackEnabled');
if (toggleGroup) {
toggleGroup.querySelectorAll('.chip-sort-btn').forEach(btn => {
const isOn = btn.dataset.val === 'on';
btn.classList.toggle('active', goldbackEnabled ? isOn : !isOn);
});
}
// Toggle — estimation enabled
const estToggle = document.getElementById('settingsGoldbackEstimateEnabled');
if (estToggle) {
estToggle.querySelectorAll('.chip-sort-btn').forEach(btn => {
const isOn = btn.dataset.val === 'on';
btn.classList.toggle('active', goldbackEstimateEnabled ? isOn : !isOn);
});
}
// Refresh button — visible only when estimation ON
const refreshBtn = document.getElementById('goldbackEstimateRefreshBtn');
if (refreshBtn) {
refreshBtn.style.display = goldbackEstimateEnabled ? '' : 'none';
}
// Modifier row — visible only when estimation ON
const modifierRow = document.getElementById('goldbackEstimateModifierRow');
if (modifierRow) {
modifierRow.style.display = goldbackEstimateEnabled ? '' : 'none';
}
const modifierInput = document.getElementById('goldbackEstimateModifierInput');
if (modifierInput) {
modifierInput.value = goldbackEstimateModifier.toFixed(2);
}
// Info line — show estimated rate + gold spot reference
const infoEl = document.getElementById('goldbackEstimateInfo');
if (infoEl) {
const goldSpot = spotPrices && spotPrices.gold ? spotPrices.gold : 0;
if (goldbackEstimateEnabled && goldSpot > 0) {
const rate = typeof computeGoldbackEstimatedRate === 'function'
? computeGoldbackEstimatedRate(goldSpot)
: 0;
const fmtRate = typeof formatCurrency === 'function' ? formatCurrency(rate) : '$' + rate.toFixed(2);
const fmtSpot = typeof formatCurrency === 'function' ? formatCurrency(goldSpot) : '$' + goldSpot.toFixed(2);
infoEl.textContent = `Estimated 1 GB rate: ${fmtRate} (gold spot: ${fmtSpot})`;
infoEl.style.display = '';
} else {
infoEl.style.display = 'none';
}
}
// Denomination table
const tbody = document.getElementById('goldbackPriceTableBody');
if (!tbody || typeof GOLDBACK_DENOMINATIONS === 'undefined') return;
tbody.innerHTML = '';
// Convert stored USD prices to display currency for the input fields (STACK-50)
const fxRate = (typeof getExchangeRate === 'function') ? getExchangeRate() : 1;
for (const d of GOLDBACK_DENOMINATIONS) {
const key = String(d.weight);
const entry = goldbackPrices[key];
const usdPrice = entry ? entry.price : '';
const displayPrice = (usdPrice !== '' && fxRate !== 1) ? (usdPrice * fxRate).toFixed(2) : usdPrice;
let updatedAt = entry && entry.updatedAt
? (typeof formatTimestamp === 'function' ? formatTimestamp(entry.updatedAt) : new Date(entry.updatedAt).toLocaleString())
: '\u2014';
if (goldbackEstimateEnabled && entry && entry.updatedAt) {
updatedAt += ' (auto)';
}
const tr = document.createElement('tr');
tr.dataset.denom = key;
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
tr.innerHTML = `
<td>${d.label}</td>
<td>${d.goldOz} oz</td>
<td><span class="gb-denom-symbol" style="margin-right:2px;">${typeof getCurrencySymbol === 'function' ? getCurrencySymbol() : '$'}</span><input type="number" min="0" step="0.01" value="${displayPrice}" style="width:80px;" /></td>
<td style="font-size:0.85em;color:var(--text-secondary);">${updatedAt}</td>
`;
tbody.appendChild(tr);
}
// Update Quick Fill currency symbol (STACK-50)
const gbQfSymbol = document.getElementById('gbQuickFillSymbol');
if (gbQfSymbol && typeof getCurrencySymbol === 'function') {
gbQfSymbol.textContent = getCurrencySymbol();
}
};
// =============================================================================
// HEADER TOGGLE & LAYOUT VISIBILITY (STACK-54)
// =============================================================================
/**
* Syncs the header shortcut checkboxes in Settings with stored state.
*/
const syncHeaderToggleUI = () => {
const themeVisible = localStorage.getItem('headerThemeBtnVisible') === 'true';
const currencyVisible = localStorage.getItem('headerCurrencyBtnVisible') === 'true';
const trendStored = localStorage.getItem(HEADER_TREND_BTN_KEY);
const trendVisible = trendStored !== null ? trendStored === 'true' : true;
const syncStored = localStorage.getItem(HEADER_SYNC_BTN_KEY);
const syncVisible = syncStored !== null ? syncStored === 'true' : true;
syncChipToggle('settingsHeaderThemeBtn', themeVisible);
syncChipToggle('settingsHeaderThemeBtn_hdr', themeVisible);
syncChipToggle('settingsHeaderCurrencyBtn', currencyVisible);
syncChipToggle('settingsHeaderCurrencyBtn_hdr', currencyVisible);
syncChipToggle('settingsHeaderTrendBtn', trendVisible);
syncChipToggle('settingsHeaderTrendBtn_hdr', trendVisible);
syncChipToggle('settingsHeaderSyncBtn', syncVisible);
syncChipToggle('settingsHeaderSyncBtn_hdr', syncVisible);
applyHeaderToggleVisibility();
};
/**
* Shows/hides the header shortcut buttons based on stored preferences.
* Theme and Currency default hidden; Trend and Sync default visible.
*/
const applyHeaderToggleVisibility = () => {
const themeVisible = localStorage.getItem('headerThemeBtnVisible') === 'true';
const currencyVisible = localStorage.getItem('headerCurrencyBtnVisible') === 'true';
const trendStored = localStorage.getItem(HEADER_TREND_BTN_KEY);
const trendVisible = trendStored !== null ? trendStored === 'true' : true;
const syncStored = localStorage.getItem(HEADER_SYNC_BTN_KEY);
const syncVisible = syncStored !== null ? syncStored === 'true' : true;
if (elements.headerThemeBtn) {
elements.headerThemeBtn.style.display = themeVisible ? '' : 'none';
}
if (elements.headerCurrencyBtn) {
elements.headerCurrencyBtn.style.display = currencyVisible ? '' : 'none';
}
safeGetElement('headerTrendBtn').style.display = trendVisible ? '' : 'none';
safeGetElement('headerSyncBtn').style.display = syncVisible ? '' : 'none';
};
window.applyHeaderToggleVisibility = applyHeaderToggleVisibility;
/**
* Syncs layout section config table in Settings and applies layout order.
*/
const syncLayoutVisibilityUI = () => {
renderLayoutSectionConfigTable();
renderViewModalSectionConfigTable();
renderMetalOrderConfigTable();
renderInlineChipConfigTable();
applyLayoutOrder();
};
/**
* Generic section-config table renderer.
* Builds a checkbox + arrow reorder table for any {id, label, enabled}[] config.
*
* @param {Object} opts
* @param {string} opts.containerId - DOM id of the container element
* @param {function} opts.getConfig - Returns the current config array
* @param {function} opts.saveConfig - Persists the updated config array
* @param {string} [opts.emptyText] - Text shown when config is empty (default: 'No sections available')
* @param {function} [opts.onApply] - Called after every change (e.g. applyLayoutOrder)
* @param {function} [opts.onRender] - Called to re-render after reorder (defaults to self)
*/
const _renderSectionConfigTable = (opts) => {
const container = document.getElementById(opts.containerId);
if (!container || typeof opts.getConfig !== 'function') return;
const config = opts.getConfig();
container.textContent = '';
if (!config.length) {
const empty = document.createElement('div');
empty.className = 'chip-grouping-empty';
empty.textContent = opts.emptyText || 'No sections available';
container.appendChild(empty);
return;
}
const table = document.createElement('table');
table.className = 'chip-grouping-table';
const tbody = document.createElement('tbody');
config.forEach((section, idx) => {
const tr = document.createElement('tr');
tr.dataset.sectionId = section.id;
// Checkbox cell
const tdCheck = document.createElement('td');
tdCheck.style.cssText = 'width:2rem;text-align:center';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = section.enabled;
cb.className = 'inline-chip-toggle';
cb.title = 'Toggle ' + section.label;
cb.addEventListener('change', () => {
const cfg = opts.getConfig();
const item = cfg.at(idx);
if (item) {
item.enabled = cb.checked;
opts.saveConfig(cfg);
if (opts.onApply) opts.onApply();
}
});
tdCheck.appendChild(cb);
// Label cell
const tdLabel = document.createElement('td');
tdLabel.textContent = section.label;
// Arrow buttons cell
const tdMove = document.createElement('td');
tdMove.style.cssText = 'width:3.5rem;text-align:right;white-space:nowrap';
const makeBtn = (dir, disabled) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'inline-chip-move';
btn.textContent = dir === 'up' ? '\u2191' : '\u2193';
btn.title = 'Move ' + dir;
btn.disabled = disabled;
btn.addEventListener('click', () => {
const cfg = opts.getConfig();
const j = dir === 'up' ? idx - 1 : idx + 1;
if (j < 0 || j >= cfg.length) return;
const moved = cfg.splice(idx, 1).at(0);
cfg.splice(j, 0, moved);
opts.saveConfig(cfg);
(opts.onRender || (() => _renderSectionConfigTable(opts)))();
if (opts.onApply) opts.onApply();
});
return btn;
};
tdMove.appendChild(makeBtn('up', idx === 0));
tdMove.appendChild(makeBtn('down', idx === config.length - 1));
tr.append(tdCheck, tdLabel, tdMove);
tbody.appendChild(tr);
});
table.appendChild(tbody);
container.appendChild(table);
};
/** Renders the main page layout section config table in Settings > Layout. */
const renderLayoutSectionConfigTable = () => _renderSectionConfigTable({
containerId: 'layoutSectionConfigContainer',
getConfig: getLayoutSectionConfig,
saveConfig: saveLayoutSectionConfig,
onApply: typeof applyLayoutOrder === 'function' ? applyLayoutOrder : null,
onRender: () => renderLayoutSectionConfigTable(),
});
/** Renders the view modal section config table in Settings > Layout. */
const renderViewModalSectionConfigTable = () => _renderSectionConfigTable({
containerId: 'viewModalSectionConfigContainer',
getConfig: getViewModalSectionConfig,
saveConfig: saveViewModalSectionConfig,
onRender: () => renderViewModalSectionConfigTable(),
});
// =============================================================================
// METAL ORDER CONFIG
// =============================================================================
const METAL_ORDER_DEFAULTS = [
{ id: 'silver', label: 'Silver', enabled: true },
{ id: 'gold', label: 'Gold', enabled: true },
{ id: 'platinum', label: 'Platinum', enabled: true },
{ id: 'palladium', label: 'Palladium', enabled: true },
{ id: 'all', label: 'All Metals', enabled: true },
];
/**
* Returns the current metal order config, merging stored data with defaults.
* New metals added to defaults will be appended to existing stored configs.
* @returns {Array<{id:string, label:string, enabled:boolean}>}
*/
const getMetalOrderConfig = () => {
const stored = localStorage.getItem(METAL_ORDER_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored);
// Append any new defaults not yet in stored config
const knownIds = new Set(parsed.map(m => m.id));
METAL_ORDER_DEFAULTS.filter(m => !knownIds.has(m.id)).forEach(m => parsed.push({ ...m }));
return parsed;
} catch (e) { /* fall through to defaults */ }
}
return METAL_ORDER_DEFAULTS.map(m => ({ ...m }));
};
const saveMetalOrderConfig = (config) => {
localStorage.setItem(METAL_ORDER_KEY, JSON.stringify(config));
};
/**
* Applies metal order config: reorders and shows/hides spot price cards and totals cards.
*/
const applyMetalOrder = () => {
const config = getMetalOrderConfig();
const spotGrid = document.querySelector('.spot-cards-grid');
const totalsEl = document.getElementById('totalsCarousel');
const spotMap = {
silver: document.querySelector('.spot-input.silver'),
gold: document.querySelector('.spot-input.gold'),
platinum: document.querySelector('.spot-input.platinum'),
palladium:document.querySelector('.spot-input.palladium'),
};
const totalsMap = {
silver: document.querySelector('.total-card.silver'),
gold: document.querySelector('.total-card.gold'),
platinum: document.querySelector('.total-card.platinum'),
palladium:document.querySelector('.total-card.palladium'),
all: document.querySelector('.total-card.total-card-all'),
};
config.forEach(({ id, enabled }) => {
const spotEl = spotMap[id];
if (spotEl && spotGrid) {
spotEl.style.display = enabled ? '' : 'none';
spotGrid.appendChild(spotEl);
}
const totalEl = totalsMap[id];
if (totalEl && totalsEl) {
totalEl.style.display = enabled ? '' : 'none';
totalsEl.appendChild(totalEl);
}
});
if (typeof window.refreshTotalsDots === 'function') window.refreshTotalsDots();
};
window.applyMetalOrder = applyMetalOrder;
/** Renders the metal order config table in Settings > Chips. */
const renderMetalOrderConfigTable = () => _renderSectionConfigTable({
containerId: 'metalOrderConfigContainer',
getConfig: getMetalOrderConfig,
saveConfig: saveMetalOrderConfig,
onApply: applyMetalOrder,
onRender: () => renderMetalOrderConfigTable(),
});
window.renderMetalOrderConfigTable = renderMetalOrderConfigTable;
/**
* Shows/hides and reorders major page sections based on layout section config.
* Reads from localStorage and applies both visibility and DOM order.
*/
const applyLayoutOrder = () => {
const config = getLayoutSectionConfig();
const sectionMap = {
spotPrices: elements.spotPricesSection,
totals: elements.totalsSectionEl,
search: elements.searchSectionEl,
table: elements.tableSectionEl,
};
const container = document.querySelector('.container');
if (!container) return;
for (const section of config) {
const el = sectionMap[section.id];
if (!el) continue;
el.style.display = section.enabled ? '' : 'none';
container.append(el);
}
};
const applyLayoutVisibility = applyLayoutOrder;
window.applyLayoutVisibility = applyLayoutVisibility;
window.applyLayoutOrder = applyLayoutOrder;
/**
* Toggles the floating currency picker dropdown anchored to the header button.
* Creates the dropdown lazily on first use; subsequent calls toggle visibility.
*/
const toggleCurrencyDropdown = () => {
const btn = document.getElementById('headerCurrencyBtn');
if (!btn) return;
// If dropdown already open, close it
const existing = document.getElementById('headerCurrencyDropdown');
if (existing) {
closeCurrencyDropdown();
return;
}
// Build dropdown
const dropdown = document.createElement('div');
dropdown.id = 'headerCurrencyDropdown';
dropdown.className = 'header-currency-dropdown';
const currentCode = displayCurrency || 'USD';
SUPPORTED_CURRENCIES.forEach(c => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'header-currency-item';
if (c.code === currentCode) item.classList.add('active');
const symbol = getCurrencySymbol(c.code);
item.textContent = `${symbol} ${c.code} — ${c.name}`;
item.addEventListener('click', (e) => {
e.stopPropagation();
saveDisplayCurrency(c.code);
if (typeof renderTable === 'function') renderTable();
if (typeof updateSummary === 'function') updateSummary();
if (typeof updateAllSparklines === 'function') updateAllSparklines();
if (typeof syncGoldbackSettingsUI === 'function') syncGoldbackSettingsUI();
// Sync settings dropdown if open
const sel = document.getElementById('settingsDisplayCurrency');
if (sel) sel.value = c.code;
closeCurrencyDropdown();
});
dropdown.appendChild(item);
});
// Position below button
document.body.appendChild(dropdown);
const rect = btn.getBoundingClientRect();
dropdown.style.top = (rect.bottom + 4) + 'px';
// Align right edge of dropdown with right edge of button
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
// Close on outside click; header button click already stops propagation
document.addEventListener('click', closeCurrencyDropdownOnOutside);
};
/** Closes the currency dropdown and removes the outside-click listener. */
const closeCurrencyDropdown = () => {
const el = document.getElementById('headerCurrencyDropdown');
if (el) el.remove();
document.removeEventListener('click', closeCurrencyDropdownOnOutside);
};
/** Click-outside handler for the currency dropdown. */
const closeCurrencyDropdownOnOutside = (e) => {
const dropdown = document.getElementById('headerCurrencyDropdown');
const btn = elements.headerCurrencyBtn;
if (dropdown && !dropdown.contains(e.target) && e.target !== btn) {
closeCurrencyDropdown();
}
};
// =============================================================================
// IMAGES SETTINGS TAB (STACK-96)
// =============================================================================
/**
* Populate all sub-sections of the Images settings tab.
*/
const populateImagesSection = () => {
renderImageStorageStats();
renderCustomPatternRules();
renderUserImageGrid();
};
/**
* Create a thumbnail element (img or placeholder) for a given blob URL.
* @param {string|null} src - Object URL or null
* @param {string} alt - Alt text
* @returns {HTMLElement}
*/
const createThumbEl = (src, alt) => {
if (src) {
const img = document.createElement('img');
img.src = src;
img.alt = alt;
img.className = 'pattern-rule-thumb';
return img;
}
const placeholder = document.createElement('div');
placeholder.className = 'pattern-rule-thumb pattern-rule-thumb-empty';
placeholder.textContent = 'No img';
return placeholder;
};
/**
* Render user-created pattern image rules with dual thumbnails, edit, and delete.
*/
const renderCustomPatternRules = async () => {
const container = document.getElementById('customPatternImageRules');
if (!container) return;
if (typeof NumistaLookup === 'undefined') {
container.innerHTML = '<p style="font-size:0.85em;color:var(--text-secondary)">NumistaLookup not available.</p>';
return;
}
const rules = NumistaLookup.listCustomRules();
if (!rules.length) {
container.innerHTML = '<p style="font-size:0.85em;color:var(--text-secondary)">No custom pattern rules yet. Use the form above to add one.</p>';
return;
}
container.textContent = '';
for (const rule of rules) {
const row = document.createElement('div');
row.className = 'pattern-rule-row';
// Dual thumbnails (obverse + reverse)
const thumbs = document.createElement('div');
thumbs.className = 'pattern-rule-thumbs';
let obverseSrc = null;
let reverseSrc = null;
if (rule.seedImageId && window.imageCache?.isAvailable()) {
try { obverseSrc = await imageCache.getPatternImageUrl(rule.seedImageId, 'obverse'); } catch { /* ignore */ }
try { reverseSrc = await imageCache.getPatternImageUrl(rule.seedImageId, 'reverse'); } catch { /* ignore */ }
}
thumbs.appendChild(createThumbEl(obverseSrc, rule.pattern + ' obverse'));
thumbs.appendChild(createThumbEl(reverseSrc, rule.pattern + ' reverse'));
row.appendChild(thumbs);
// Info
const info = document.createElement('div');
info.className = 'pattern-rule-info';
info.innerHTML = `<div class="rule-pattern">/${sanitizeHtml(rule.pattern)}/i</div>
<div class="rule-replacement">${sanitizeHtml(rule.replacement) || '\u2014'}${rule.numistaId ? ' (N#' + sanitizeHtml(String(rule.numistaId)) + ')' : ''}</div>`;
row.appendChild(info);
// Actions
const actions = document.createElement('div');
actions.className = 'pattern-rule-actions';
const editBtn = document.createElement('button');
editBtn.className = 'btn';
editBtn.textContent = 'Edit';
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn danger';
deleteBtn.textContent = 'Delete';
deleteBtn.addEventListener('click', async () => {
NumistaLookup.removeRule(rule.id);
if (rule.seedImageId && window.imageCache?.isAvailable()) {
await imageCache.deletePatternImage(rule.seedImageId);
}
renderCustomPatternRules();
renderImageStorageStats();
});
actions.appendChild(editBtn);
actions.appendChild(deleteBtn);
row.appendChild(actions);
// Inline edit form (hidden by default)
const editForm = document.createElement('div');
editForm.className = 'pattern-rule-edit-form';
editForm.style.display = 'none';
editForm.innerHTML = `
<div class="edit-form-fields">
<label>Pattern <input type="text" class="edit-pattern" value="${rule.pattern.replace(/"/g, '"')}" /></label>
<label>Replacement <input type="text" class="edit-replacement" value="${(rule.replacement || '').replace(/"/g, '"')}" /></label>
<label>N# <input type="text" class="edit-numista-id" value="${rule.numistaId || ''}" /></label>
<label>Obverse <input type="file" class="edit-obverse" accept="image/*" /></label>
<label>Reverse <input type="file" class="edit-reverse" accept="image/*" /></label>
</div>
<div class="edit-form-actions">
<button type="button" class="btn edit-save-btn">Save</button>
<button type="button" class="btn edit-cancel-btn">Cancel</button>
</div>`;
// Toggle edit form
editBtn.addEventListener('click', () => {
const isVisible = editForm.style.display !== 'none';
editForm.style.display = isVisible ? 'none' : 'block';
editBtn.textContent = isVisible ? 'Edit' : 'Editing...';
});
// Cancel
editForm.querySelector('.edit-cancel-btn').addEventListener('click', () => {
editForm.style.display = 'none';
editBtn.textContent = 'Edit';
});
// Save
editForm.querySelector('.edit-save-btn').addEventListener('click', async () => {
const newPattern = editForm.querySelector('.edit-pattern').value.trim();
const newReplacement = editForm.querySelector('.edit-replacement').value.trim();
const newNumistaId = editForm.querySelector('.edit-numista-id').value.trim();
if (!newPattern || !newReplacement) {
alert('Pattern and replacement are required.');
return;
}
const result = NumistaLookup.updateRule(rule.id, {
pattern: newPattern,
replacement: newReplacement,
numistaId: newNumistaId || null
});
if (!result.success) {
alert(result.error || 'Failed to update rule.');
return;
}
// Handle new image uploads
const obvFile = editForm.querySelector('.edit-obverse').files[0];
const revFile = editForm.querySelector('.edit-reverse').files[0];
if ((obvFile || revFile) && window.imageCache?.isAvailable()) {
const ruleId = rule.seedImageId || rule.id;
const processor = typeof imageProcessor !== 'undefined' ? imageProcessor : null;
let obvBlob = null;
let revBlob = null;
try {
if (obvFile) {
obvBlob = processor ? (await processor.processFile(obvFile))?.blob || null : obvFile;
}
if (revFile) {
revBlob = processor ? (await processor.processFile(revFile))?.blob || null : revFile;
}
} catch (err) {
console.error('Image processing failed:', err);
alert('Failed to process image: ' + err.message);
return;
}
// Preserve existing side when only one side is uploaded
if (rule.seedImageId && !(obvFile && revFile)) {
const existing = await imageCache.getPatternImage(rule.seedImageId);
await imageCache.cachePatternImage(ruleId, obvBlob || existing?.obverse || null, revBlob || existing?.reverse || null);
} else {
await imageCache.cachePatternImage(ruleId, obvBlob, revBlob);
}
// Ensure seedImageId is set on the rule
if (!rule.seedImageId) {
NumistaLookup.updateRule(rule.id, { seedImageId: ruleId });
}
}
renderCustomPatternRules();
renderImageStorageStats();
});
row.appendChild(editForm);
container.appendChild(row);
}
};
/**
* Render storage statistics for the image system.
*/
const renderImageStorageStats = async () => {
const container = document.getElementById('imageStorageStats');
if (!container) return;
if (!window.imageCache?.isAvailable()) {
container.innerHTML = '<span class="stat-item">IndexedDB unavailable</span>';
return;
}
const usage = await imageCache.getStorageUsage();
const totalMB = (usage.totalBytes / 1024 / 1024).toFixed(1);
const limitMB = (usage.limitBytes / 1024 / 1024).toFixed(0);
const pct = usage.limitBytes > 0 ? ((usage.totalBytes / usage.limitBytes) * 100).toFixed(1) : 0;
container.innerHTML = `
<span class="stat-item">Total: ${totalMB} / ${limitMB} MB (${pct}%)</span>
<span class="stat-item">Numista: ${usage.numistaCount}</span>
<span class="stat-item">Pattern: ${usage.patternImageCount || 0}</span>
<span class="stat-item">User: ${usage.userImageCount}</span>
<span class="stat-item">Metadata: ${usage.metadataCount}</span>
`;
};
/**
* Render user-uploaded images as rows with dual thumbnails, edit link, and delete.
*/
const renderUserImageGrid = async () => {
const container = document.getElementById('userImageGrid');
if (!container) return;
if (!window.imageCache?.isAvailable()) {
container.innerHTML = '<p style="font-size:0.85em;color:var(--text-secondary)">IndexedDB unavailable.</p>';
return;
}
let userImages;
try {
userImages = await imageCache.exportAllUserImages();
} catch {
container.innerHTML = '<p style="font-size:0.85em;color:var(--text-secondary)">Could not load user images.</p>';
return;
}
if (!userImages?.length) {
container.innerHTML = '<p style="font-size:0.85em;color:var(--text-secondary)">No user-uploaded images yet.</p>';
return;
}
container.textContent = '';
for (const rec of userImages) {
const row = document.createElement('div');
row.className = 'pattern-rule-row';
// Dual thumbnails
const thumbs = document.createElement('div');
thumbs.className = 'pattern-rule-thumbs';
let obverseSrc = null;
let reverseSrc = null;
if (rec.obverse) { try { obverseSrc = URL.createObjectURL(rec.obverse); } catch { /* ignore */ } }
if (rec.reverse) { try { reverseSrc = URL.createObjectURL(rec.reverse); } catch { /* ignore */ } }
thumbs.appendChild(createThumbEl(obverseSrc, 'obverse'));
thumbs.appendChild(createThumbEl(reverseSrc, 'reverse'));
row.appendChild(thumbs);
// Item name
const item = typeof inventory !== 'undefined' ? inventory.find(i => i.uuid === rec.uuid) : null;
const itemIndex = item && typeof inventory !== 'undefined' ? inventory.indexOf(item) : -1;
const name = item ? item.name : rec.uuid.slice(0, 8) + '...';
const info = document.createElement('div');
info.className = 'pattern-rule-info';
info.innerHTML = `<div class="rule-replacement">${name}</div>`;
row.appendChild(info);
// Actions
const actions = document.createElement('div');
actions.className = 'pattern-rule-actions';
// Edit link — opens item's edit modal
if (itemIndex >= 0) {
const editLink = document.createElement('button');
editLink.className = 'btn';
editLink.textContent = 'Edit';
editLink.addEventListener('click', () => {
hideSettingsModal();
if (typeof editItem === 'function') editItem(itemIndex);
});
actions.appendChild(editLink);
}
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn danger';
deleteBtn.textContent = 'Delete';
deleteBtn.addEventListener('click', async () => {
if (!confirm('Delete images for "' + name + '"?')) return;
await imageCache.deleteUserImage(rec.uuid);
renderUserImageGrid();
renderImageStorageStats();
});
actions.appendChild(deleteBtn);
row.appendChild(actions);
container.appendChild(row);
}
};
// =============================================================================
// STORAGE SECTION
// =============================================================================
/** Friendly display names for known localStorage keys */
const STORAGE_KEY_LABELS = {
metalInventory: { label: 'Inventory Data', icon: '📋', category: 'Inventory' },
inventorySerial: { label: 'Item Serial Counter', icon: '🔢', category: 'Inventory' },
catalogMap: { label: 'Catalog Map', icon: '🗂', category: 'Inventory' },
itemTags: { label: 'Item Tags', icon: '🏷', category: 'Inventory' },
changeLog: { label: 'Change Log', icon: '📝', category: 'Inventory' },
metalSpotHistory: { label: 'Spot Price History', icon: '📈', category: 'Prices' },
'item-price-history': { label: 'Item Price History', icon: '💰', category: 'Prices' },
'goldback-prices': { label: 'Goldback Prices', icon: '🥇', category: 'Prices' },
'goldback-price-history': { label: 'Goldback Price History', icon: '🥇', category: 'Prices' },
spotSilver: { label: 'Silver Spot (live)', icon: '🪙', category: 'Prices' },
spotGold: { label: 'Gold Spot (live)', icon: '🪙', category: 'Prices' },
spotPlatinum: { label: 'Platinum Spot (live)', icon: '🪙', category: 'Prices' },
spotPalladium: { label: 'Palladium Spot (live)', icon: '🪙', category: 'Prices' },
metalApiConfig: { label: 'API Configuration', icon: '🔑', category: 'API & Cache' },
metalApiCache: { label: 'API Cache', icon: '⚡', category: 'API & Cache' },
lastCacheRefresh: { label: 'Last Cache Refresh', icon: '⏱', category: 'API & Cache' },
lastApiSync: { label: 'Last API Sync', icon: '⏱', category: 'API & Cache' },
apiProviderOrder: { label: 'API Provider Order', icon: '↕', category: 'API & Cache' },
providerPriority: { label: 'Provider Priority', icon: '↕', category: 'API & Cache' },
'autocomplete_lookup_cache': { label: 'Autocomplete Cache', icon: '⚡', category: 'API & Cache' },
'autocomplete_cache_timestamp': { label: 'Autocomplete Cache Stamp', icon: '⏱', category: 'API & Cache' },
'staktrakr.catalog.cache': { label: 'Catalog Cache', icon: '⚡', category: 'API & Cache' },
'staktrakr.catalog.history': { label: 'Catalog Call History', icon: '📜', category: 'API & Cache' },
'catalog_api_config': { label: 'Catalog API Config', icon: '🔑', category: 'API & Cache' },
exchangeRates: { label: 'Exchange Rates', icon: '💱', category: 'API & Cache' },
appTheme: { label: 'Theme', icon: '🎨', category: 'Settings' },
displayCurrency: { label: 'Display Currency', icon: '💱', category: 'Settings' },
appTimeZone: { label: 'Timezone', icon: '🕐', category: 'Settings' },
settingsItemsPerPage: { label: 'Items Per Page', icon: '⚙️', category: 'Settings' },
cardViewStyle: { label: 'Card View Style', icon: '⚙️', category: 'Settings' },
desktopCardView: { label: 'Desktop Card View', icon: '⚙️', category: 'Settings' },
defaultSortColumn: { label: 'Default Sort Column', icon: '⚙️', category: 'Settings' },
defaultSortDir: { label: 'Default Sort Direction', icon: '⚙️', category: 'Settings' },
metalOrderConfig: { label: 'Metal Order / Visibility', icon: '⚙️', category: 'Settings' },
layoutVisibility: { label: 'Layout Visibility', icon: '⚙️', category: 'Settings' },
layoutSectionConfig: { label: 'Layout Section Config', icon: '⚙️', category: 'Settings' },
viewModalSectionConfig: { label: 'View Modal Section Config', icon: '⚙️', category: 'Settings' },
chipMinCount: { label: 'Chip Min Count', icon: '⚙️', category: 'Settings' },
chipCustomGroups: { label: 'Chip Custom Groups', icon: '⚙️', category: 'Settings' },
chipBlacklist: { label: 'Chip Blacklist', icon: '⚙️', category: 'Settings' },
inlineChipConfig: { label: 'Inline Chip Config', icon: '⚙️', category: 'Settings' },
filterChipCategoryConfig: { label: 'Filter Chip Categories', icon: '⚙️', category: 'Settings' },
chipSortOrder: { label: 'Chip Sort Order', icon: '⚙️', category: 'Settings' },
numistaLookupRules: { label: 'Numista Lookup Rules', icon: '🔍', category: 'Settings' },
numistaViewFields: { label: 'Numista View Fields', icon: '🔍', category: 'Settings' },
numistaOverridePersonal: { label: 'Numista Image Priority', icon: '🔍', category: 'Settings' },
'staktrakr.catalog.settings': { label: 'Catalog Settings', icon: '🔍', category: 'Settings' },
tableImagesEnabled: { label: 'Table Images', icon: '🖼', category: 'Settings' },
tableImageSides: { label: 'Table Image Sides', icon: '🖼', category: 'Settings' },
enabledSeedRules: { label: 'Enabled Seed Rules', icon: '🌱', category: 'Settings' },
featureFlags: { label: 'Feature Flags', icon: '🚩', category: 'Settings' },
headerTrendBtnVisible: { label: 'Trend Btn Visible', icon: '⚙️', category: 'Settings' },
headerSyncBtnVisible: { label: 'Sync Btn Visible', icon: '⚙️', category: 'Settings' },
headerThemeBtnVisible: { label: 'Theme Btn Visible', icon: '⚙️', category: 'Settings' },
headerCurrencyBtnVisible: { label: 'Currency Btn Visible', icon: '⚙️', category: 'Settings' },
spotTrendRange: { label: 'Spot Trend Range', icon: '📈', category: 'Settings' },
spotCompareMode: { label: 'Spot Compare Mode', icon: '📈', category: 'Settings' },
spotTrendPeriod: { label: 'Spot Trend Period', icon: '📈', category: 'Settings' },
'goldback-enabled': { label: 'Goldback Enabled', icon: '🥇', category: 'Settings' },
'goldback-estimate-enabled': { label: 'Goldback Estimate On', icon: '🥇', category: 'Settings' },
'goldback-estimate-modifier': { label: 'Goldback Modifier', icon: '🥇', category: 'Settings' },
cloud_token_dropbox: { label: 'Dropbox Token', icon: '☁️', category: 'Cloud & Auth' },
cloud_token_pcloud: { label: 'pCloud Token', icon: '☁️', category: 'Cloud & Auth' },
cloud_token_box: { label: 'Box Token', icon: '☁️', category: 'Cloud & Auth' },
cloud_last_backup: { label: 'Last Cloud Backup', icon: '☁️', category: 'Cloud & Auth' },
cloud_activity_log: { label: 'Cloud Activity Log', icon: '☁️', category: 'Cloud & Auth' },
cloud_kraken_seen: { label: 'Cloud Onboarding Seen', icon: '☁️', category: 'Cloud & Auth' },
staktrakr_oauth_result: { label: 'OAuth Result', icon: '🔐', category: 'Cloud & Auth' },
currentAppVersion: { label: 'App Version (stored)', icon: 'ℹ️', category: 'App State' },
ackVersion: { label: 'Acknowledged Version', icon: 'ℹ️', category: 'App State' },
ackDismissed: { label: 'Acknowledgment Dismissed', icon: 'ℹ️', category: 'App State' },
lastVersionCheck: { label: 'Last Version Check', icon: 'ℹ️', category: 'App State' },
latestRemoteVersion: { label: 'Latest Remote Version', icon: 'ℹ️', category: 'App State' },
latestRemoteUrl: { label: 'Latest Remote URL', icon: 'ℹ️', category: 'App State' },
seedImagesVer: { label: 'Seed Images Version', icon: '🌱', category: 'App State' },
ff_migration_fuzzy_autocomplete: { label: 'Migration: Fuzzy Autocomplete', icon: '🔄', category: 'App State' },
migration_hourlySource: { label: 'Migration: Hourly Source', icon: '🔄', category: 'App State' },
'staktrakr.debug': { label: 'Debug Flag', icon: '🐛', category: 'App State' },
'stackrtrackr.debug': { label: 'Debug Flag (legacy typo)', icon: '🐛', category: 'App State' },
};
/** Keys under this KB threshold are considered "minor" and hidden by default */
const STORAGE_TINY_THRESHOLD_KB = 0.5;
/** True = show minor keys; toggled by the button in the panel */
let _storageTinyVisible = false;
/**
* Populates the Storage settings panel with live LS and IDB data.
* @param {boolean} [silent=false] - If true, skip the loading spinner on refresh
*/
const renderStorageSection = async (silent = false) => {
const keyTable = document.getElementById('storageKeyTable');
const idbTable = document.getElementById('storageIdbTable');
if (!keyTable) return;
if (!silent) {
keyTable.innerHTML = '<div class="storage-key-table-loading">Loading…</div>';
if (idbTable) idbTable.innerHTML = '<div class="storage-key-table-loading">Loading…</div>';
}
// ── 1. Gather localStorage data ──────────────────────────────────────────
const lsItems = [];
let lsTotalKB = 0;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const val = localStorage.getItem(key) || '';
const sizeKB = ((key.length + val.length) * 2) / 1024;
lsTotalKB += sizeKB;
let type = 'String', records = null;
try {
const parsed = JSON.parse(val);
if (Array.isArray(parsed)) { type = 'Array'; records = parsed.length; }
else if (parsed && typeof parsed === 'object') { type = 'Object'; records = Object.keys(parsed).length; }
else { type = 'Value'; }
} catch (e) { /* not JSON */ }
const meta = STORAGE_KEY_LABELS[key] || {};
lsItems.push({ key, sizeKB, type, records, label: meta.label || key, icon: meta.icon || '📄', category: meta.category || 'Other' });
}
lsItems.sort((a, b) => b.sizeKB - a.sizeKB);
// ── 2. Gather IndexedDB data ──────────────────────────────────────────────
let idbStats = null;
if (window.imageCache?.isAvailable()) {
try { idbStats = await imageCache.getStorageUsage(); } catch (e) { /* unavailable */ }
}
const idbTotalKB = idbStats ? idbStats.totalBytes / 1024 : 0;
const idbLimitKB = idbStats ? idbStats.limitBytes / 1024 : 50 * 1024;
const lsLimitKB = 5 * 1024;
const combinedKB = lsTotalKB + idbTotalKB;
const combinedLimitKB = lsLimitKB + idbLimitKB;
// ── 3. Update summary stat cards ─────────────────────────────────────────
const fmt = (kb) => kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${kb.toFixed(1)} KB`;
const pct = (used, limit) => limit > 0 ? Math.min((used / limit) * 100, 100) : 0;
const setCard = (id, val, sub, barId, barPct, barClass) => {
const el = document.getElementById(id);
if (el) el.textContent = val;
const subEl = document.getElementById(`${id}_sub`);
if (subEl) subEl.textContent = sub;
const bar = document.getElementById(barId);
if (bar) { bar.style.width = `${barPct.toFixed(1)}%`; if (barClass) bar.className = `storage-stat-bar ${barClass}`; }
};
setCard('storageStat_ls', fmt(lsTotalKB), `${pct(lsTotalKB, lsLimitKB).toFixed(1)}% of 5,120 KB`, 'storageStatBar_ls', pct(lsTotalKB, lsLimitKB), 'storage-stat-bar--ls');
setCard('storageStat_idb', fmt(idbTotalKB), `${pct(idbTotalKB, idbLimitKB).toFixed(1)}% of ${fmt(idbLimitKB)}`, 'storageStatBar_idb', pct(idbTotalKB, idbLimitKB), 'storage-stat-bar--idb');
setCard('storageStat_combined', fmt(combinedKB), `of ~${fmt(combinedLimitKB)} cap`, 'storageStatBar_combined_ls', pct(lsTotalKB, combinedLimitKB), 'storage-stat-bar--ls');
const combinedIdbBar = document.getElementById('storageStatBar_combined_idb');
if (combinedIdbBar) combinedIdbBar.style.width = `${pct(idbTotalKB, combinedLimitKB).toFixed(1)}%`;
// ── 4. Render localStorage key table ─────────────────────────────────────
const major = lsItems.filter(it => it.sizeKB >= STORAGE_TINY_THRESHOLD_KB);
const minor = lsItems.filter(it => it.sizeKB < STORAGE_TINY_THRESHOLD_KB);
const visible = _storageTinyVisible ? lsItems : major;
const rowsHtml = visible.map(it => {
const barPct = lsTotalKB > 0 ? Math.min((it.sizeKB / lsTotalKB) * 100, 100) : 0;
const recStr = it.records !== null ? it.records.toLocaleString() : '—';
const sizeStr = it.sizeKB >= 1 ? `${it.sizeKB.toFixed(1)} KB` : `${(it.sizeKB * 1024).toFixed(0)} B`;
return `<tr class="storage-key-row">
<td class="storage-key-icon">${it.icon}</td>
<td class="storage-key-label">${sanitizeHtml(it.label)}<span class="storage-key-raw">${sanitizeHtml(it.key)}</span></td>
<td class="storage-key-size">${sizeStr}</td>
<td class="storage-key-bar-cell"><div class="storage-key-bar-wrap"><div class="storage-key-bar" style="width:${barPct.toFixed(1)}%"></div></div></td>
<td class="storage-key-pct">${barPct.toFixed(1)}%</td>
<td class="storage-key-type"><span class="storage-type-badge storage-type-badge--${it.type.toLowerCase()}">${it.type}</span></td>
<td class="storage-key-records">${recStr}</td>
</tr>`;
}).join('');
keyTable.innerHTML = `
<table class="storage-data-table">
<thead><tr>
<th></th>
<th>Key</th>
<th>Size</th>
<th class="storage-col-bar">Usage</th>
<th>%</th>
<th>Type</th>
<th>Records</th>
</tr></thead>
<tbody>${rowsHtml}</tbody>
</table>
${minor.length > 0 ? `<p class="storage-minor-note">${_storageTinyVisible ? '' : `${minor.length} minor keys hidden. `}<button class="btn-link storage-toggle-tiny" id="storageToggleTinyBottom">${_storageTinyVisible ? 'Hide minor keys' : 'Show all'}</button></p>` : ''}
`;
// wire bottom toggle
const bottomToggle = document.getElementById('storageToggleTinyBottom');
if (bottomToggle) bottomToggle.addEventListener('click', _handleStorageTinyToggle);
// ── 5. Render IndexedDB table ─────────────────────────────────────────────
if (idbTable) {
if (!idbStats) {
idbTable.innerHTML = '<p class="settings-subtext">IndexedDB unavailable in this browser.</p>';
} else {
const idbRows = [
{ label: 'Coin Images', icon: '🖼', count: idbStats.numistaCount, sizeKB: null },
{ label: 'User Images', icon: '📷', count: idbStats.userImageCount, sizeKB: null },
{ label: 'Pattern Images', icon: '🎨', count: idbStats.patternImageCount || 0, sizeKB: null },
{ label: 'Coin Metadata', icon: '📄', count: idbStats.metadataCount, sizeKB: null },
];
// Estimate size by proportion of total (exact per-store breakdown not available from getStorageUsage)
const idbTotalCount = idbRows.reduce((s, r) => s + r.count, 0) || 1;
idbRows.forEach(r => { r.sizeKB = idbTotalCount > 0 ? (r.count / idbTotalCount) * idbTotalKB : 0; });
const idbRowsHtml = idbRows.map(r => {
const barPct = idbTotalKB > 0 ? Math.min((r.sizeKB / idbTotalKB) * 100, 100) : 0;
const sizeStr = r.sizeKB >= 1024 ? `${(r.sizeKB / 1024).toFixed(1)} MB` : `${r.sizeKB.toFixed(1)} KB`;
return `<tr class="storage-key-row">
<td class="storage-key-icon">${r.icon}</td>
<td class="storage-key-label">${r.label}<span class="storage-key-raw">StakTrakrImages</span></td>
<td class="storage-key-size">~${sizeStr}</td>
<td class="storage-key-bar-cell"><div class="storage-key-bar-wrap"><div class="storage-key-bar storage-key-bar--idb" style="width:${barPct.toFixed(1)}%"></div></div></td>
<td class="storage-key-pct">${barPct.toFixed(1)}%</td>
<td class="storage-key-type"><span class="storage-type-badge storage-type-badge--idb">IDB</span></td>
<td class="storage-key-records">${r.count.toLocaleString()}</td>
</tr>`;
}).join('');
const idbTotalStr = idbTotalKB >= 1024 ? `${(idbTotalKB / 1024).toFixed(1)} MB` : `${idbTotalKB.toFixed(1)} KB`;
const idbLimitStr = idbLimitKB >= 1024 ? `${(idbLimitKB / 1024).toFixed(0)} MB` : `${idbLimitKB.toFixed(0)} KB`;
idbTable.innerHTML = `
<table class="storage-data-table">
<thead><tr>
<th></th>
<th>Store</th>
<th>~Size</th>
<th class="storage-col-bar">Usage</th>
<th>%</th>
<th>Type</th>
<th>Records</th>
</tr></thead>
<tbody>${idbRowsHtml}</tbody>
</table>
<p class="storage-minor-note">Total: ${idbTotalStr} / ${idbLimitStr} · Size per store is estimated proportionally from record count.</p>
`;
}
}
// ── 6. Update top toggle button text ─────────────────────────────────────
const topToggle = document.getElementById('storageToggleTiny');
if (topToggle) topToggle.textContent = _storageTinyVisible ? 'Hide minor keys' : `Show minor keys (${minor.length})`;
};
const _handleStorageTinyToggle = () => {
_storageTinyVisible = !_storageTinyVisible;
renderStorageSection(true);
};
// Expose globally
if (typeof window !== 'undefined') {
window.showSettingsModal = showSettingsModal;
window.hideSettingsModal = hideSettingsModal;
window.switchSettingsSection = switchSettingsSection;
window.switchProviderTab = switchProviderTab;
window.renderInlineChipConfigTable = renderInlineChipConfigTable;
window.renderFilterChipCategoryTable = renderFilterChipCategoryTable;
window.renderLayoutSectionConfigTable = renderLayoutSectionConfigTable;
window.syncGoldbackSettingsUI = syncGoldbackSettingsUI;
window.syncCurrencySettingsUI = syncCurrencySettingsUI;
window.syncHeaderToggleUI = syncHeaderToggleUI;
window.syncLayoutVisibilityUI = syncLayoutVisibilityUI;
window.renderSeedRuleTable = renderSeedRuleTable;
window.renderCustomRuleTable = renderCustomRuleTable;
window.populateImagesSection = populateImagesSection;
window.renderStorageSection = renderStorageSection;
}