Refactoring + various fixes. (#285)

This commit is contained in:
Gerlof van Ek
2025-03-26 23:54:55 +01:00
committed by GitHub
parent 65cf6789a7
commit d5f48ce6b9
41 changed files with 7374 additions and 5021 deletions
+389
View File
@@ -0,0 +1,389 @@
const ApiManager = (function() {
const state = {
isInitialized: false
};
const config = {
requestTimeout: 60000,
retryDelays: [5000, 15000, 30000],
rateLimits: {
coingecko: {
requestsPerMinute: 50,
minInterval: 1200
},
cryptocompare: {
requestsPerMinute: 30,
minInterval: 2000
}
}
};
const rateLimiter = {
lastRequestTime: {},
minRequestInterval: {
coingecko: 1200,
cryptocompare: 2000
},
requestQueue: {},
retryDelays: [5000, 15000, 30000],
canMakeRequest: function(apiName) {
const now = Date.now();
const lastRequest = this.lastRequestTime[apiName] || 0;
return (now - lastRequest) >= this.minRequestInterval[apiName];
},
updateLastRequestTime: function(apiName) {
this.lastRequestTime[apiName] = Date.now();
},
getWaitTime: function(apiName) {
const now = Date.now();
const lastRequest = this.lastRequestTime[apiName] || 0;
return Math.max(0, this.minRequestInterval[apiName] - (now - lastRequest));
},
queueRequest: async function(apiName, requestFn, retryCount = 0) {
if (!this.requestQueue[apiName]) {
this.requestQueue[apiName] = Promise.resolve();
}
try {
await this.requestQueue[apiName];
const executeRequest = async () => {
const waitTime = this.getWaitTime(apiName);
if (waitTime > 0) {
await new Promise(resolve => setTimeout(resolve, waitTime));
}
try {
this.updateLastRequestTime(apiName);
return await requestFn();
} catch (error) {
if (error.message.includes('429') && retryCount < this.retryDelays.length) {
const delay = this.retryDelays[retryCount];
console.log(`Rate limit hit, retrying in ${delay/1000} seconds...`);
await new Promise(resolve => setTimeout(resolve, delay));
return publicAPI.rateLimiter.queueRequest(apiName, requestFn, retryCount + 1);
}
if ((error.message.includes('timeout') || error.name === 'NetworkError') &&
retryCount < this.retryDelays.length) {
const delay = this.retryDelays[retryCount];
console.warn(`Request failed, retrying in ${delay/1000} seconds...`, {
apiName,
retryCount,
error: error.message
});
await new Promise(resolve => setTimeout(resolve, delay));
return publicAPI.rateLimiter.queueRequest(apiName, requestFn, retryCount + 1);
}
throw error;
}
};
this.requestQueue[apiName] = executeRequest();
return await this.requestQueue[apiName];
} catch (error) {
if (error.message.includes('429') ||
error.message.includes('timeout') ||
error.name === 'NetworkError') {
const cacheKey = `coinData_${apiName}`;
try {
const cachedData = JSON.parse(localStorage.getItem(cacheKey));
if (cachedData && cachedData.value) {
return cachedData.value;
}
} catch (e) {
console.warn('Error accessing cached data:', e);
}
}
throw error;
}
}
};
const publicAPI = {
config,
rateLimiter,
initialize: function(options = {}) {
if (state.isInitialized) {
console.warn('[ApiManager] Already initialized');
return this;
}
if (options.config) {
Object.assign(config, options.config);
}
if (config.rateLimits) {
Object.keys(config.rateLimits).forEach(api => {
if (config.rateLimits[api].minInterval) {
rateLimiter.minRequestInterval[api] = config.rateLimits[api].minInterval;
}
});
}
if (config.retryDelays) {
rateLimiter.retryDelays = [...config.retryDelays];
}
if (window.CleanupManager) {
window.CleanupManager.registerResource('apiManager', this, (mgr) => mgr.dispose());
}
state.isInitialized = true;
console.log('ApiManager initialized');
return this;
},
makeRequest: async function(url, method = 'GET', headers = {}, body = null) {
try {
const options = {
method: method,
headers: {
'Content-Type': 'application/json',
...headers
},
signal: AbortSignal.timeout(config.requestTimeout)
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error(`Request failed for ${url}:`, error);
throw error;
}
},
makePostRequest: async function(url, headers = {}) {
return new Promise((resolve, reject) => {
fetch('/json/readurl', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
url: url,
headers: headers
})
})
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
if (data.Error) {
reject(new Error(data.Error));
} else {
resolve(data);
}
})
.catch(error => {
console.error(`Request failed for ${url}:`, error);
reject(error);
});
});
},
fetchCoinPrices: async function(coins, source = "coingecko.com", ttl = 300) {
if (!Array.isArray(coins)) {
coins = [coins];
}
return this.makeRequest('/json/coinprices', 'POST', {}, {
coins: Array.isArray(coins) ? coins.join(',') : coins,
source: source,
ttl: ttl
});
},
fetchCoinGeckoData: async function() {
return this.rateLimiter.queueRequest('coingecko', async () => {
try {
const coins = (window.config && window.config.coins) ?
window.config.coins
.filter(coin => coin.usesCoinGecko)
.map(coin => coin.name)
.join(',') :
'bitcoin,monero,particl,bitcoincash,pivx,firo,dash,litecoin,dogecoin,decred';
//console.log('Fetching coin prices for:', coins);
const response = await this.fetchCoinPrices(coins);
//console.log('Full API response:', response);
if (!response || typeof response !== 'object') {
throw new Error('Invalid response type');
}
if (!response.rates || typeof response.rates !== 'object' || Object.keys(response.rates).length === 0) {
throw new Error('No valid rates found in response');
}
return response;
} catch (error) {
console.error('Error in fetchCoinGeckoData:', {
message: error.message,
stack: error.stack
});
throw error;
}
});
},
fetchVolumeData: async function() {
return this.rateLimiter.queueRequest('coingecko', async () => {
try {
const coins = (window.config && window.config.coins) ?
window.config.coins
.filter(coin => coin.usesCoinGecko)
.map(coin => getCoinBackendId ? getCoinBackendId(coin.name) : coin.name)
.join(',') :
'bitcoin,monero,particl,bitcoin-cash,pivx,firo,dash,litecoin,dogecoin,decred';
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${coins}&vs_currencies=usd&include_24hr_vol=true&include_24hr_change=true`;
const response = await this.makePostRequest(url, {
'User-Agent': 'Mozilla/5.0',
'Accept': 'application/json'
});
const volumeData = {};
Object.entries(response).forEach(([coinId, data]) => {
if (data && data.usd_24h_vol) {
volumeData[coinId] = {
total_volume: data.usd_24h_vol,
price_change_percentage_24h: data.usd_24h_change || 0
};
}
});
return volumeData;
} catch (error) {
console.error("Error fetching volume data:", error);
throw error;
}
});
},
fetchCryptoCompareData: function(coin) {
return this.rateLimiter.queueRequest('cryptocompare', async () => {
try {
const apiKey = window.config?.apiKeys?.cryptoCompare || '';
const url = `https://min-api.cryptocompare.com/data/pricemultifull?fsyms=${coin}&tsyms=USD,BTC&api_key=${apiKey}`;
const headers = {
'User-Agent': 'Mozilla/5.0',
'Accept': 'application/json'
};
return await this.makePostRequest(url, headers);
} catch (error) {
console.error(`CryptoCompare request failed for ${coin}:`, error);
throw error;
}
});
},
fetchHistoricalData: async function(coinSymbols, resolution = 'day') {
if (!Array.isArray(coinSymbols)) {
coinSymbols = [coinSymbols];
}
const results = {};
const fetchPromises = coinSymbols.map(async coin => {
if (coin === 'WOW') {
return this.rateLimiter.queueRequest('coingecko', async () => {
const url = `https://api.coingecko.com/api/v3/coins/wownero/market_chart?vs_currency=usd&days=1`;
try {
const response = await this.makePostRequest(url);
if (response && response.prices) {
results[coin] = response.prices;
}
} catch (error) {
console.error(`Error fetching CoinGecko data for WOW:`, error);
throw error;
}
});
} else {
return this.rateLimiter.queueRequest('cryptocompare', async () => {
try {
const apiKey = window.config?.apiKeys?.cryptoCompare || '';
let url;
if (resolution === 'day') {
url = `https://min-api.cryptocompare.com/data/v2/histohour?fsym=${coin}&tsym=USD&limit=24&api_key=${apiKey}`;
} else if (resolution === 'year') {
url = `https://min-api.cryptocompare.com/data/v2/histoday?fsym=${coin}&tsym=USD&limit=365&api_key=${apiKey}`;
} else {
url = `https://min-api.cryptocompare.com/data/v2/histoday?fsym=${coin}&tsym=USD&limit=180&api_key=${apiKey}`;
}
const response = await this.makePostRequest(url);
if (response.Response === "Error") {
console.error(`API Error for ${coin}:`, response.Message);
throw new Error(response.Message);
} else if (response.Data && response.Data.Data) {
results[coin] = response.Data;
}
} catch (error) {
console.error(`Error fetching CryptoCompare data for ${coin}:`, error);
throw error;
}
});
}
});
await Promise.all(fetchPromises);
return results;
},
dispose: function() {
// Clear any pending requests or resources
rateLimiter.requestQueue = {};
rateLimiter.lastRequestTime = {};
state.isInitialized = false;
console.log('ApiManager disposed');
}
};
return publicAPI;
})();
function getCoinBackendId(coinName) {
const nameMap = {
'bitcoin-cash': 'bitcoincash',
'bitcoin cash': 'bitcoincash',
'firo': 'zcoin',
'zcoin': 'zcoin',
'bitcoincash': 'bitcoin-cash'
};
return nameMap[coinName.toLowerCase()] || coinName.toLowerCase();
}
window.Api = ApiManager;
window.ApiManager = ApiManager;
document.addEventListener('DOMContentLoaded', function() {
if (!window.apiManagerInitialized) {
ApiManager.initialize();
window.apiManagerInitialized = true;
}
});
//console.log('ApiManager initialized with methods:', Object.keys(ApiManager));
console.log('ApiManager initialized');
@@ -0,0 +1,535 @@
const CacheManager = (function() {
const defaults = window.config?.cacheConfig?.storage || {
maxSizeBytes: 10 * 1024 * 1024,
maxItems: 200,
defaultTTL: 5 * 60 * 1000
};
const PRICES_CACHE_KEY = 'crypto_prices_unified';
const CACHE_KEY_PATTERNS = [
'coinData_',
'chartData_',
'historical_',
'rates_',
'prices_',
'offers_',
'fallback_',
'volumeData'
];
const isCacheKey = (key) => {
return CACHE_KEY_PATTERNS.some(pattern => key.startsWith(pattern)) ||
key === 'coinGeckoOneLiner' ||
key === PRICES_CACHE_KEY;
};
const isLocalStorageAvailable = () => {
try {
const testKey = '__storage_test__';
localStorage.setItem(testKey, testKey);
localStorage.removeItem(testKey);
return true;
} catch (e) {
return false;
}
};
let storageAvailable = isLocalStorageAvailable();
const memoryCache = new Map();
if (!storageAvailable) {
console.warn('localStorage is not available. Using in-memory cache instead.');
}
const cacheAPI = {
getTTL: function(resourceType) {
const ttlConfig = window.config?.cacheConfig?.ttlSettings || {};
return ttlConfig[resourceType] || window.config?.cacheConfig?.defaultTTL || defaults.defaultTTL;
},
set: function(key, value, resourceTypeOrCustomTtl = null) {
try {
this.cleanup();
if (!value) {
console.warn('Attempted to cache null/undefined value for key:', key);
return false;
}
let ttl;
if (typeof resourceTypeOrCustomTtl === 'string') {
ttl = this.getTTL(resourceTypeOrCustomTtl);
} else if (typeof resourceTypeOrCustomTtl === 'number') {
ttl = resourceTypeOrCustomTtl;
} else {
ttl = window.config?.cacheConfig?.defaultTTL || defaults.defaultTTL;
}
const item = {
value: value,
timestamp: Date.now(),
expiresAt: Date.now() + ttl
};
let serializedItem;
try {
serializedItem = JSON.stringify(item);
} catch (e) {
console.error('Failed to serialize cache item:', e);
return false;
}
const itemSize = new Blob([serializedItem]).size;
if (itemSize > defaults.maxSizeBytes) {
console.warn(`Cache item exceeds maximum size (${(itemSize/1024/1024).toFixed(2)}MB)`);
return false;
}
if (storageAvailable) {
try {
localStorage.setItem(key, serializedItem);
return true;
} catch (storageError) {
if (storageError.name === 'QuotaExceededError') {
this.cleanup(true);
try {
localStorage.setItem(key, serializedItem);
return true;
} catch (retryError) {
console.error('Storage quota exceeded even after cleanup:', retryError);
storageAvailable = false;
console.warn('Switching to in-memory cache due to quota issues');
memoryCache.set(key, item);
return true;
}
} else {
console.error('localStorage error:', storageError);
storageAvailable = false;
console.warn('Switching to in-memory cache due to localStorage error');
memoryCache.set(key, item);
return true;
}
}
} else {
memoryCache.set(key, item);
if (memoryCache.size > defaults.maxItems) {
const keysToDelete = Array.from(memoryCache.keys())
.filter(k => isCacheKey(k))
.sort((a, b) => memoryCache.get(a).timestamp - memoryCache.get(b).timestamp)
.slice(0, Math.floor(memoryCache.size * 0.2)); // Remove oldest 20%
keysToDelete.forEach(k => memoryCache.delete(k));
}
return true;
}
} catch (error) {
console.error('Cache set error:', error);
try {
memoryCache.set(key, {
value: value,
timestamp: Date.now(),
expiresAt: Date.now() + (window.config?.cacheConfig?.defaultTTL || defaults.defaultTTL)
});
return true;
} catch (e) {
console.error('Memory cache set error:', e);
return false;
}
}
},
get: function(key) {
try {
if (storageAvailable) {
try {
const itemStr = localStorage.getItem(key);
if (itemStr) {
let item;
try {
item = JSON.parse(itemStr);
} catch (parseError) {
console.error('Failed to parse cached item:', parseError);
localStorage.removeItem(key);
return null;
}
if (!item || typeof item.expiresAt !== 'number' || !Object.prototype.hasOwnProperty.call(item, 'value')) {
console.warn('Invalid cache item structure for key:', key);
localStorage.removeItem(key);
return null;
}
const now = Date.now();
if (now < item.expiresAt) {
return {
value: item.value,
remainingTime: item.expiresAt - now
};
}
localStorage.removeItem(key);
return null;
}
} catch (error) {
console.error("localStorage access error:", error);
storageAvailable = false;
console.warn('Switching to in-memory cache due to localStorage error');
}
}
if (memoryCache.has(key)) {
const item = memoryCache.get(key);
const now = Date.now();
if (now < item.expiresAt) {
return {
value: item.value,
remainingTime: item.expiresAt - now
};
} else {
memoryCache.delete(key);
}
}
return null;
} catch (error) {
console.error("Cache retrieval error:", error);
try {
if (storageAvailable) {
localStorage.removeItem(key);
}
memoryCache.delete(key);
} catch (removeError) {
console.error("Failed to remove invalid cache entry:", removeError);
}
return null;
}
},
isValid: function(key) {
return this.get(key) !== null;
},
cleanup: function(aggressive = false) {
const now = Date.now();
let totalSize = 0;
let itemCount = 0;
const items = [];
if (storageAvailable) {
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!isCacheKey(key)) continue;
try {
const itemStr = localStorage.getItem(key);
const size = new Blob([itemStr]).size;
const item = JSON.parse(itemStr);
if (now >= item.expiresAt) {
localStorage.removeItem(key);
continue;
}
items.push({
key,
size,
expiresAt: item.expiresAt,
timestamp: item.timestamp
});
totalSize += size;
itemCount++;
} catch (error) {
console.error("Error processing cache item:", error);
localStorage.removeItem(key);
}
}
if (aggressive || totalSize > defaults.maxSizeBytes || itemCount > defaults.maxItems) {
items.sort((a, b) => b.timestamp - a.timestamp);
while ((totalSize > defaults.maxSizeBytes || itemCount > defaults.maxItems) && items.length > 0) {
const item = items.pop();
try {
localStorage.removeItem(item.key);
totalSize -= item.size;
itemCount--;
} catch (error) {
console.error("Error removing cache item:", error);
}
}
}
} catch (error) {
console.error("Error during localStorage cleanup:", error);
storageAvailable = false;
console.warn('Switching to in-memory cache due to localStorage error');
}
}
const expiredKeys = [];
memoryCache.forEach((item, key) => {
if (now >= item.expiresAt) {
expiredKeys.push(key);
}
});
expiredKeys.forEach(key => memoryCache.delete(key));
if (aggressive && memoryCache.size > defaults.maxItems / 2) {
const keysToDelete = Array.from(memoryCache.keys())
.filter(key => isCacheKey(key))
.sort((a, b) => memoryCache.get(a).timestamp - memoryCache.get(b).timestamp)
.slice(0, Math.floor(memoryCache.size * 0.3)); // Remove oldest 30% during aggressive cleanup
keysToDelete.forEach(key => memoryCache.delete(key));
}
return {
totalSize,
itemCount,
memoryCacheSize: memoryCache.size,
cleaned: items.length,
storageAvailable
};
},
clear: function() {
if (storageAvailable) {
try {
const keys = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (isCacheKey(key)) {
keys.push(key);
}
}
keys.forEach(key => {
try {
localStorage.removeItem(key);
} catch (error) {
console.error("Error clearing cache item:", error);
}
});
} catch (error) {
console.error("Error clearing localStorage cache:", error);
storageAvailable = false;
}
}
Array.from(memoryCache.keys())
.filter(key => isCacheKey(key))
.forEach(key => memoryCache.delete(key));
console.log("Cache cleared successfully");
return true;
},
getStats: function() {
let totalSize = 0;
let itemCount = 0;
let expiredCount = 0;
const now = Date.now();
if (storageAvailable) {
try {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!isCacheKey(key)) continue;
try {
const itemStr = localStorage.getItem(key);
const size = new Blob([itemStr]).size;
const item = JSON.parse(itemStr);
totalSize += size;
itemCount++;
if (now >= item.expiresAt) {
expiredCount++;
}
} catch (error) {
console.error("Error getting cache stats:", error);
}
}
} catch (error) {
console.error("Error getting localStorage stats:", error);
storageAvailable = false;
}
}
let memoryCacheSize = 0;
let memoryCacheItems = 0;
let memoryCacheExpired = 0;
memoryCache.forEach((item, key) => {
if (isCacheKey(key)) {
memoryCacheItems++;
if (now >= item.expiresAt) {
memoryCacheExpired++;
}
try {
memoryCacheSize += new Blob([JSON.stringify(item)]).size;
} catch (e) {
}
}
});
return {
totalSizeMB: (totalSize / 1024 / 1024).toFixed(2),
itemCount,
expiredCount,
utilization: ((totalSize / defaults.maxSizeBytes) * 100).toFixed(1) + '%',
memoryCacheItems,
memoryCacheExpired,
memoryCacheSizeKB: (memoryCacheSize / 1024).toFixed(2),
storageType: storageAvailable ? 'localStorage' : 'memory'
};
},
checkStorage: function() {
const wasAvailable = storageAvailable;
storageAvailable = isLocalStorageAvailable();
if (storageAvailable && !wasAvailable && memoryCache.size > 0) {
console.log('localStorage is now available. Migrating memory cache...');
let migratedCount = 0;
memoryCache.forEach((item, key) => {
if (isCacheKey(key)) {
try {
localStorage.setItem(key, JSON.stringify(item));
memoryCache.delete(key);
migratedCount++;
} catch (e) {
if (e.name === 'QuotaExceededError') {
console.warn('Storage quota exceeded during migration. Keeping items in memory cache.');
return false;
}
}
}
});
console.log(`Migrated ${migratedCount} items from memory cache to localStorage.`);
}
return {
available: storageAvailable,
type: storageAvailable ? 'localStorage' : 'memory'
};
}
};
const publicAPI = {
...cacheAPI,
setPrices: function(priceData, customTtl = null) {
return this.set(PRICES_CACHE_KEY, priceData,
customTtl || (typeof customTtl === 'undefined' ? 'prices' : null));
},
getPrices: function() {
return this.get(PRICES_CACHE_KEY);
},
getCoinPrice: function(symbol) {
const prices = this.getPrices();
if (!prices || !prices.value) {
return null;
}
const normalizedSymbol = symbol.toLowerCase();
return prices.value[normalizedSymbol] || null;
},
getCompatiblePrices: function(format) {
const prices = this.getPrices();
if (!prices || !prices.value) {
return null;
}
switch(format) {
case 'rates':
const ratesFormat = {};
Object.entries(prices.value).forEach(([coin, data]) => {
const coinKey = coin.replace(/-/g, ' ')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
.toLowerCase()
.replace(' ', '-');
ratesFormat[coinKey] = {
usd: data.price || data.usd,
btc: data.price_btc || data.btc
};
});
return {
value: ratesFormat,
remainingTime: prices.remainingTime
};
case 'coinGecko':
const geckoFormat = {};
Object.entries(prices.value).forEach(([coin, data]) => {
const symbol = this.getSymbolFromCoinId(coin);
if (symbol) {
geckoFormat[symbol.toLowerCase()] = {
current_price: data.price || data.usd,
price_btc: data.price_btc || data.btc,
total_volume: data.total_volume,
price_change_percentage_24h: data.price_change_percentage_24h,
displayName: symbol
};
}
});
return {
value: geckoFormat,
remainingTime: prices.remainingTime
};
default:
return prices;
}
},
getSymbolFromCoinId: function(coinId) {
const symbolMap = {
'bitcoin': 'BTC',
'litecoin': 'LTC',
'monero': 'XMR',
'particl': 'PART',
'pivx': 'PIVX',
'firo': 'FIRO',
'zcoin': 'FIRO',
'dash': 'DASH',
'decred': 'DCR',
'wownero': 'WOW',
'bitcoin-cash': 'BCH',
'dogecoin': 'DOGE'
};
return symbolMap[coinId] || null;
}
};
if (window.CleanupManager) {
window.CleanupManager.registerResource('cacheManager', publicAPI, (cm) => {
cm.clear();
});
}
return publicAPI;
})();
window.CacheManager = CacheManager;
//console.log('CacheManager initialized with methods:', Object.keys(CacheManager));
console.log('CacheManager initialized');
@@ -0,0 +1,270 @@
const CleanupManager = (function() {
const state = {
eventListeners: [],
timeouts: [],
intervals: [],
animationFrames: [],
resources: new Map(),
debug: false
};
function log(message, ...args) {
if (state.debug) {
console.log(`[CleanupManager] ${message}`, ...args);
}
}
const publicAPI = {
addListener: function(element, type, handler, options = false) {
if (!element) {
log('Warning: Attempted to add listener to null/undefined element');
return handler;
}
element.addEventListener(type, handler, options);
state.eventListeners.push({ element, type, handler, options });
log(`Added ${type} listener to`, element);
return handler;
},
setTimeout: function(callback, delay) {
const id = window.setTimeout(callback, delay);
state.timeouts.push(id);
log(`Created timeout ${id} with ${delay}ms delay`);
return id;
},
setInterval: function(callback, delay) {
const id = window.setInterval(callback, delay);
state.intervals.push(id);
log(`Created interval ${id} with ${delay}ms delay`);
return id;
},
requestAnimationFrame: function(callback) {
const id = window.requestAnimationFrame(callback);
state.animationFrames.push(id);
log(`Requested animation frame ${id}`);
return id;
},
registerResource: function(type, resource, cleanupFn) {
const id = `${type}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
state.resources.set(id, { resource, cleanupFn });
log(`Registered custom resource ${id} of type ${type}`);
return id;
},
unregisterResource: function(id) {
const resourceInfo = state.resources.get(id);
if (resourceInfo) {
try {
resourceInfo.cleanupFn(resourceInfo.resource);
state.resources.delete(id);
log(`Unregistered and cleaned up resource ${id}`);
return true;
} catch (error) {
console.error(`[CleanupManager] Error cleaning up resource ${id}:`, error);
return false;
}
}
log(`Resource ${id} not found`);
return false;
},
clearTimeout: function(id) {
const index = state.timeouts.indexOf(id);
if (index !== -1) {
window.clearTimeout(id);
state.timeouts.splice(index, 1);
log(`Cleared timeout ${id}`);
}
},
clearInterval: function(id) {
const index = state.intervals.indexOf(id);
if (index !== -1) {
window.clearInterval(id);
state.intervals.splice(index, 1);
log(`Cleared interval ${id}`);
}
},
cancelAnimationFrame: function(id) {
const index = state.animationFrames.indexOf(id);
if (index !== -1) {
window.cancelAnimationFrame(id);
state.animationFrames.splice(index, 1);
log(`Cancelled animation frame ${id}`);
}
},
removeListener: function(element, type, handler, options = false) {
if (!element) return;
try {
element.removeEventListener(type, handler, options);
log(`Removed ${type} listener from`, element);
} catch (error) {
console.error(`[CleanupManager] Error removing event listener:`, error);
}
state.eventListeners = state.eventListeners.filter(
listener => !(listener.element === element &&
listener.type === type &&
listener.handler === handler)
);
},
removeListenersByElement: function(element) {
if (!element) return;
const listenersToRemove = state.eventListeners.filter(
listener => listener.element === element
);
listenersToRemove.forEach(({ element, type, handler, options }) => {
try {
element.removeEventListener(type, handler, options);
log(`Removed ${type} listener from`, element);
} catch (error) {
console.error(`[CleanupManager] Error removing event listener:`, error);
}
});
state.eventListeners = state.eventListeners.filter(
listener => listener.element !== element
);
},
clearAllTimeouts: function() {
state.timeouts.forEach(id => {
window.clearTimeout(id);
});
const count = state.timeouts.length;
state.timeouts = [];
log(`Cleared all timeouts (${count})`);
},
clearAllIntervals: function() {
state.intervals.forEach(id => {
window.clearInterval(id);
});
const count = state.intervals.length;
state.intervals = [];
log(`Cleared all intervals (${count})`);
},
clearAllAnimationFrames: function() {
state.animationFrames.forEach(id => {
window.cancelAnimationFrame(id);
});
const count = state.animationFrames.length;
state.animationFrames = [];
log(`Cancelled all animation frames (${count})`);
},
clearAllResources: function() {
let successCount = 0;
let errorCount = 0;
state.resources.forEach((resourceInfo, id) => {
try {
resourceInfo.cleanupFn(resourceInfo.resource);
successCount++;
} catch (error) {
console.error(`[CleanupManager] Error cleaning up resource ${id}:`, error);
errorCount++;
}
});
state.resources.clear();
log(`Cleared all custom resources (${successCount} success, ${errorCount} errors)`);
},
clearAllListeners: function() {
state.eventListeners.forEach(({ element, type, handler, options }) => {
if (element) {
try {
element.removeEventListener(type, handler, options);
} catch (error) {
console.error(`[CleanupManager] Error removing event listener:`, error);
}
}
});
const count = state.eventListeners.length;
state.eventListeners = [];
log(`Removed all event listeners (${count})`);
},
clearAll: function() {
const counts = {
listeners: state.eventListeners.length,
timeouts: state.timeouts.length,
intervals: state.intervals.length,
animationFrames: state.animationFrames.length,
resources: state.resources.size
};
this.clearAllListeners();
this.clearAllTimeouts();
this.clearAllIntervals();
this.clearAllAnimationFrames();
this.clearAllResources();
log(`All resources cleaned up:`, counts);
return counts;
},
getResourceCounts: function() {
return {
listeners: state.eventListeners.length,
timeouts: state.timeouts.length,
intervals: state.intervals.length,
animationFrames: state.animationFrames.length,
resources: state.resources.size,
total: state.eventListeners.length +
state.timeouts.length +
state.intervals.length +
state.animationFrames.length +
state.resources.size
};
},
setDebugMode: function(enabled) {
state.debug = Boolean(enabled);
log(`Debug mode ${state.debug ? 'enabled' : 'disabled'}`);
return state.debug;
},
dispose: function() {
this.clearAll();
log('CleanupManager disposed');
},
initialize: function(options = {}) {
if (options.debug !== undefined) {
this.setDebugMode(options.debug);
}
log('CleanupManager initialized');
return this;
}
};
return publicAPI;
})();
window.CleanupManager = CleanupManager;
document.addEventListener('DOMContentLoaded', function() {
if (!window.cleanupManagerInitialized) {
CleanupManager.initialize();
window.cleanupManagerInitialized = true;
}
});
//console.log('CleanupManager initialized with methods:', Object.keys(CleanupManager));
console.log('CleanupManager initialized');
@@ -0,0 +1,414 @@
const ConfigManager = (function() {
const state = {
isInitialized: false
};
function determineWebSocketPort() {
const wsPort =
window.ws_port ||
(typeof getWebSocketConfig === 'function' ? getWebSocketConfig().port : null) ||
'11700';
return wsPort;
}
const selectedWsPort = determineWebSocketPort();
const defaultConfig = {
cacheDuration: 10 * 60 * 1000,
requestTimeout: 60000,
wsPort: selectedWsPort,
cacheConfig: {
defaultTTL: 10 * 60 * 1000,
ttlSettings: {
prices: 5 * 60 * 1000,
chart: 5 * 60 * 1000,
historical: 60 * 60 * 1000,
volume: 30 * 60 * 1000,
offers: 2 * 60 * 1000,
identity: 15 * 60 * 1000
},
storage: {
maxSizeBytes: 10 * 1024 * 1024,
maxItems: 200
},
fallbackTTL: 24 * 60 * 60 * 1000
},
itemsPerPage: 50,
apiEndpoints: {
cryptoCompare: 'https://min-api.cryptocompare.com/data/pricemultifull',
coinGecko: 'https://api.coingecko.com/api/v3',
cryptoCompareHistorical: 'https://min-api.cryptocompare.com/data/v2/histoday',
cryptoCompareHourly: 'https://min-api.cryptocompare.com/data/v2/histohour',
volumeEndpoint: 'https://api.coingecko.com/api/v3/simple/price'
},
rateLimits: {
coingecko: {
requestsPerMinute: 50,
minInterval: 1200
},
cryptocompare: {
requestsPerMinute: 30,
minInterval: 2000
}
},
retryDelays: [5000, 15000, 30000],
coins: [
{ symbol: 'BTC', name: 'bitcoin', usesCryptoCompare: false, usesCoinGecko: true, historicalDays: 30 },
{ symbol: 'XMR', name: 'monero', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 },
{ symbol: 'PART', name: 'particl', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 },
{ symbol: 'BCH', name: 'bitcoincash', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 },
{ symbol: 'PIVX', name: 'pivx', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 },
{ symbol: 'FIRO', name: 'firo', displayName: 'Firo', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 },
{ symbol: 'DASH', name: 'dash', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 },
{ symbol: 'LTC', name: 'litecoin', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 },
{ symbol: 'DOGE', name: 'dogecoin', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 },
{ symbol: 'DCR', name: 'decred', usesCryptoCompare: true, usesCoinGecko: true, historicalDays: 30 },
{ symbol: 'WOW', name: 'wownero', usesCryptoCompare: false, usesCoinGecko: true, historicalDays: 30 }
],
coinMappings: {
nameToSymbol: {
'Bitcoin': 'BTC',
'Litecoin': 'LTC',
'Monero': 'XMR',
'Particl': 'PART',
'Particl Blind': 'PART',
'Particl Anon': 'PART',
'PIVX': 'PIVX',
'Firo': 'FIRO',
'Zcoin': 'FIRO',
'Dash': 'DASH',
'Decred': 'DCR',
'Wownero': 'WOW',
'Bitcoin Cash': 'BCH',
'Dogecoin': 'DOGE'
},
nameToDisplayName: {
'Bitcoin': 'Bitcoin',
'Litecoin': 'Litecoin',
'Monero': 'Monero',
'Particl': 'Particl',
'Particl Blind': 'Particl Blind',
'Particl Anon': 'Particl Anon',
'PIVX': 'PIVX',
'Firo': 'Firo',
'Zcoin': 'Firo',
'Dash': 'Dash',
'Decred': 'Decred',
'Wownero': 'Wownero',
'Bitcoin Cash': 'Bitcoin Cash',
'Dogecoin': 'Dogecoin'
},
idToName: {
1: 'particl', 2: 'bitcoin', 3: 'litecoin', 4: 'decred',
6: 'monero', 7: 'particl blind', 8: 'particl anon',
9: 'wownero', 11: 'pivx', 13: 'firo', 17: 'bitcoincash',
18: 'dogecoin'
},
nameToCoinGecko: {
'bitcoin': 'bitcoin',
'monero': 'monero',
'particl': 'particl',
'bitcoin cash': 'bitcoin-cash',
'bitcoincash': 'bitcoin-cash',
'pivx': 'pivx',
'firo': 'firo',
'zcoin': 'firo',
'dash': 'dash',
'litecoin': 'litecoin',
'dogecoin': 'dogecoin',
'decred': 'decred',
'wownero': 'wownero'
}
},
chartConfig: {
colors: {
default: {
lineColor: 'rgba(77, 132, 240, 1)',
backgroundColor: 'rgba(77, 132, 240, 0.1)'
}
},
showVolume: false,
specialCoins: [''],
resolutions: {
year: { days: 365, interval: 'month' },
sixMonths: { days: 180, interval: 'daily' },
day: { days: 1, interval: 'hourly' }
},
currentResolution: 'year'
}
};
const publicAPI = {
...defaultConfig,
initialize: function(options = {}) {
if (state.isInitialized) {
console.warn('[ConfigManager] Already initialized');
return this;
}
if (options) {
Object.assign(this, options);
}
if (window.CleanupManager) {
window.CleanupManager.registerResource('configManager', this, (mgr) => mgr.dispose());
}
this.utils = utils;
state.isInitialized = true;
console.log('ConfigManager initialized');
return this;
},
getAPIKeys: function() {
if (typeof window.getAPIKeys === 'function') {
const apiKeys = window.getAPIKeys();
return {
cryptoCompare: apiKeys.cryptoCompare || '',
coinGecko: apiKeys.coinGecko || ''
};
}
return {
cryptoCompare: '',
coinGecko: ''
};
},
getCoinBackendId: function(coinName) {
if (!coinName) return null;
const nameMap = {
'bitcoin-cash': 'bitcoincash',
'bitcoin cash': 'bitcoincash',
'firo': 'firo',
'zcoin': 'firo',
'bitcoincash': 'bitcoin-cash'
};
const lowerCoinName = typeof coinName === 'string' ? coinName.toLowerCase() : '';
return nameMap[lowerCoinName] || lowerCoinName;
},
coinMatches: function(offerCoin, filterCoin) {
if (!offerCoin || !filterCoin) return false;
offerCoin = offerCoin.toLowerCase();
filterCoin = filterCoin.toLowerCase();
if (offerCoin === filterCoin) return true;
if ((offerCoin === 'firo' || offerCoin === 'zcoin') &&
(filterCoin === 'firo' || filterCoin === 'zcoin')) {
return true;
}
if ((offerCoin === 'bitcoincash' && filterCoin === 'bitcoin cash') ||
(offerCoin === 'bitcoin cash' && filterCoin === 'bitcoincash')) {
return true;
}
const particlVariants = ['particl', 'particl anon', 'particl blind'];
if (filterCoin === 'particl' && particlVariants.includes(offerCoin)) {
return true;
}
if (particlVariants.includes(filterCoin)) {
return offerCoin === filterCoin;
}
return false;
},
update: function(path, value) {
const parts = path.split('.');
let current = this;
for (let i = 0; i < parts.length - 1; i++) {
if (!current[parts[i]]) {
current[parts[i]] = {};
}
current = current[parts[i]];
}
current[parts[parts.length - 1]] = value;
return this;
},
get: function(path, defaultValue = null) {
const parts = path.split('.');
let current = this;
for (let i = 0; i < parts.length; i++) {
if (current === undefined || current === null) {
return defaultValue;
}
current = current[parts[i]];
}
return current !== undefined ? current : defaultValue;
},
dispose: function() {
state.isInitialized = false;
console.log('ConfigManager disposed');
}
};
const utils = {
formatNumber: function(number, decimals = 2) {
if (typeof number !== 'number' || isNaN(number)) {
console.warn('formatNumber received a non-number value:', number);
return '0';
}
try {
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals
}).format(number);
} catch (e) {
return '0';
}
},
formatDate: function(timestamp, resolution) {
const date = new Date(timestamp);
const options = {
day: { hour: '2-digit', minute: '2-digit', hour12: true },
week: { month: 'short', day: 'numeric' },
month: { year: 'numeric', month: 'short', day: 'numeric' }
};
return date.toLocaleString('en-US', { ...options[resolution], timeZone: 'UTC' });
},
debounce: function(func, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
},
formatTimeLeft: function(timestamp) {
const now = Math.floor(Date.now() / 1000);
if (timestamp <= now) return "Expired";
return this.formatTime(timestamp);
},
formatTime: function(timestamp, addAgoSuffix = false) {
const now = Math.floor(Date.now() / 1000);
const diff = Math.abs(now - timestamp);
let timeString;
if (diff < 60) {
timeString = `${diff} seconds`;
} else if (diff < 3600) {
timeString = `${Math.floor(diff / 60)} minutes`;
} else if (diff < 86400) {
timeString = `${Math.floor(diff / 3600)} hours`;
} else if (diff < 2592000) {
timeString = `${Math.floor(diff / 86400)} days`;
} else if (diff < 31536000) {
timeString = `${Math.floor(diff / 2592000)} months`;
} else {
timeString = `${Math.floor(diff / 31536000)} years`;
}
return addAgoSuffix ? `${timeString} ago` : timeString;
},
escapeHtml: function(unsafe) {
if (typeof unsafe !== 'string') {
console.warn('escapeHtml received a non-string value:', unsafe);
return '';
}
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
},
formatPrice: function(coin, price) {
if (typeof price !== 'number' || isNaN(price)) {
console.warn(`Invalid price for ${coin}:`, price);
return 'N/A';
}
if (price < 0.000001) return price.toExponential(2);
if (price < 0.001) return price.toFixed(8);
if (price < 1) return price.toFixed(4);
if (price < 10) return price.toFixed(3);
if (price < 1000) return price.toFixed(2);
if (price < 100000) return price.toFixed(1);
return price.toFixed(0);
},
getEmptyPriceData: function() {
return {
'bitcoin': { usd: null, btc: null },
'bitcoin-cash': { usd: null, btc: null },
'dash': { usd: null, btc: null },
'dogecoin': { usd: null, btc: null },
'decred': { usd: null, btc: null },
'litecoin': { usd: null, btc: null },
'particl': { usd: null, btc: null },
'pivx': { usd: null, btc: null },
'monero': { usd: null, btc: null },
'zano': { usd: null, btc: null },
'wownero': { usd: null, btc: null },
'firo': { usd: null, btc: null }
};
},
getCoinSymbol: function(fullName) {
return publicAPI.coinMappings?.nameToSymbol[fullName] || fullName;
}
};
return publicAPI;
})();
window.logger = {
log: function(message) {
console.log(`[AppLog] ${new Date().toISOString()}: ${message}`);
},
warn: function(message) {
console.warn(`[AppWarn] ${new Date().toISOString()}: ${message}`);
},
error: function(message) {
console.error(`[AppError] ${new Date().toISOString()}: ${message}`);
}
};
window.config = ConfigManager;
document.addEventListener('DOMContentLoaded', function() {
if (!window.configManagerInitialized) {
ConfigManager.initialize();
window.configManagerInitialized = true;
}
});
if (typeof module !== 'undefined') {
module.exports = ConfigManager;
}
//console.log('ConfigManager initialized with properties:', Object.keys(ConfigManager));
console.log('ConfigManager initialized');
@@ -0,0 +1,192 @@
const IdentityManager = (function() {
const state = {
cache: new Map(),
pendingRequests: new Map(),
config: {
retryDelay: 2000,
maxRetries: 3,
maxCacheSize: 100,
cacheTimeout: window.config?.cacheConfig?.ttlSettings?.identity || 15 * 60 * 1000,
debug: false
}
};
function log(message, ...args) {
if (state.config.debug) {
console.log(`[IdentityManager] ${message}`, ...args);
}
}
const publicAPI = {
getIdentityData: async function(address) {
if (!address) {
return null;
}
const cachedData = this.getCachedIdentity(address);
if (cachedData) {
log(`Cache hit for ${address}`);
return cachedData;
}
if (state.pendingRequests.has(address)) {
log(`Using pending request for ${address}`);
return state.pendingRequests.get(address);
}
log(`Fetching identity for ${address}`);
const request = fetchWithRetry(address);
state.pendingRequests.set(address, request);
try {
const data = await request;
this.setCachedIdentity(address, data);
return data;
} finally {
state.pendingRequests.delete(address);
}
},
getCachedIdentity: function(address) {
const cached = state.cache.get(address);
if (cached && (Date.now() - cached.timestamp) < state.config.cacheTimeout) {
return cached.data;
}
return null;
},
setCachedIdentity: function(address, data) {
if (state.cache.size >= state.config.maxCacheSize) {
const oldestEntries = [...state.cache.entries()]
.sort((a, b) => a[1].timestamp - b[1].timestamp)
.slice(0, Math.floor(state.config.maxCacheSize * 0.2));
oldestEntries.forEach(([key]) => {
state.cache.delete(key);
log(`Pruned cache entry for ${key}`);
});
}
state.cache.set(address, {
data,
timestamp: Date.now()
});
log(`Cached identity for ${address}`);
},
clearCache: function() {
log(`Clearing identity cache (${state.cache.size} entries)`);
state.cache.clear();
state.pendingRequests.clear();
},
limitCacheSize: function(maxSize = state.config.maxCacheSize) {
if (state.cache.size <= maxSize) {
return 0;
}
const entriesToRemove = [...state.cache.entries()]
.sort((a, b) => a[1].timestamp - b[1].timestamp)
.slice(0, state.cache.size - maxSize);
entriesToRemove.forEach(([key]) => state.cache.delete(key));
log(`Limited cache size, removed ${entriesToRemove.length} entries`);
return entriesToRemove.length;
},
getCacheSize: function() {
return state.cache.size;
},
configure: function(options = {}) {
Object.assign(state.config, options);
log(`Configuration updated:`, state.config);
return state.config;
},
getStats: function() {
const now = Date.now();
let expiredCount = 0;
let totalSize = 0;
state.cache.forEach((value, key) => {
if (now - value.timestamp > state.config.cacheTimeout) {
expiredCount++;
}
const keySize = key.length * 2;
const dataSize = JSON.stringify(value.data).length * 2;
totalSize += keySize + dataSize;
});
return {
cacheEntries: state.cache.size,
pendingRequests: state.pendingRequests.size,
expiredEntries: expiredCount,
estimatedSizeKB: Math.round(totalSize / 1024),
config: { ...state.config }
};
},
setDebugMode: function(enabled) {
state.config.debug = Boolean(enabled);
return `Debug mode ${state.config.debug ? 'enabled' : 'disabled'}`;
},
initialize: function(options = {}) {
if (options) {
this.configure(options);
}
if (window.CleanupManager) {
window.CleanupManager.registerResource('identityManager', this, (mgr) => mgr.dispose());
}
log('IdentityManager initialized');
return this;
},
dispose: function() {
this.clearCache();
log('IdentityManager disposed');
}
};
async function fetchWithRetry(address, attempt = 1) {
try {
const response = await fetch(`/json/identities/${address}`, {
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
if (attempt >= state.config.maxRetries) {
console.error(`[IdentityManager] Error:`, error.message);
console.warn(`[IdentityManager] Failed to fetch identity for ${address} after ${attempt} attempts`);
return null;
}
await new Promise(resolve => setTimeout(resolve, state.config.retryDelay * attempt));
return fetchWithRetry(address, attempt + 1);
}
}
return publicAPI;
})();
window.IdentityManager = IdentityManager;
document.addEventListener('DOMContentLoaded', function() {
if (!window.identityManagerInitialized) {
IdentityManager.initialize();
window.identityManagerInitialized = true;
}
});
//console.log('IdentityManager initialized with methods:', Object.keys(IdentityManager));
console.log('IdentityManager initialized');
@@ -0,0 +1,219 @@
const MemoryManager = (function() {
const state = {
isMonitoringEnabled: false,
monitorInterval: null,
cleanupInterval: null
};
const config = {
monitorInterval: 30000,
cleanupInterval: 60000,
debug: false
};
function log(message, ...args) {
if (config.debug) {
console.log(`[MemoryManager] ${message}`, ...args);
}
}
const publicAPI = {
enableMonitoring: function(interval = config.monitorInterval) {
if (state.monitorInterval) {
clearInterval(state.monitorInterval);
}
state.isMonitoringEnabled = true;
config.monitorInterval = interval;
this.logMemoryUsage();
state.monitorInterval = setInterval(() => {
this.logMemoryUsage();
}, interval);
console.log(`Memory monitoring enabled - reporting every ${interval/1000} seconds`);
return true;
},
disableMonitoring: function() {
if (state.monitorInterval) {
clearInterval(state.monitorInterval);
state.monitorInterval = null;
}
state.isMonitoringEnabled = false;
console.log('Memory monitoring disabled');
return true;
},
logMemoryUsage: function() {
const timestamp = new Date().toLocaleTimeString();
console.log(`=== Memory Monitor [${timestamp}] ===`);
if (window.performance && window.performance.memory) {
console.log('Memory usage:', {
usedJSHeapSize: (window.performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(2) + ' MB',
totalJSHeapSize: (window.performance.memory.totalJSHeapSize / 1024 / 1024).toFixed(2) + ' MB'
});
}
if (navigator.deviceMemory) {
console.log('Device memory:', navigator.deviceMemory, 'GB');
}
const nodeCount = document.querySelectorAll('*').length;
console.log('DOM node count:', nodeCount);
if (window.CleanupManager) {
const counts = CleanupManager.getResourceCounts();
console.log('Managed resources:', counts);
}
if (window.TooltipManager) {
const tooltipInstances = document.querySelectorAll('[data-tippy-root]').length;
const tooltipTriggers = document.querySelectorAll('[data-tooltip-trigger-id]').length;
console.log('Tooltip instances:', tooltipInstances, '- Tooltip triggers:', tooltipTriggers);
}
if (window.CacheManager && window.CacheManager.getStats) {
const cacheStats = CacheManager.getStats();
console.log('Cache stats:', cacheStats);
}
if (window.IdentityManager && window.IdentityManager.getStats) {
const identityStats = window.IdentityManager.getStats();
console.log('Identity cache stats:', identityStats);
}
console.log('==============================');
},
enableAutoCleanup: function(interval = config.cleanupInterval) {
if (state.cleanupInterval) {
clearInterval(state.cleanupInterval);
}
config.cleanupInterval = interval;
this.forceCleanup();
state.cleanupInterval = setInterval(() => {
this.forceCleanup();
}, interval);
log('Auto-cleanup enabled every', interval/1000, 'seconds');
return true;
},
disableAutoCleanup: function() {
if (state.cleanupInterval) {
clearInterval(state.cleanupInterval);
state.cleanupInterval = null;
}
console.log('Memory auto-cleanup disabled');
return true;
},
forceCleanup: function() {
if (config.debug) {
console.log('Running memory cleanup...', new Date().toLocaleTimeString());
}
if (window.CacheManager && CacheManager.cleanup) {
CacheManager.cleanup(true);
}
if (window.TooltipManager && TooltipManager.cleanup) {
window.TooltipManager.cleanup();
}
document.querySelectorAll('[data-tooltip-trigger-id]').forEach(element => {
if (window.TooltipManager && TooltipManager.destroy) {
window.TooltipManager.destroy(element);
}
});
if (window.chartModule && chartModule.cleanup) {
chartModule.cleanup();
}
if (window.gc) {
window.gc();
} else {
const arr = new Array(1000);
for (let i = 0; i < 1000; i++) {
arr[i] = new Array(10000).join('x');
}
}
if (config.debug) {
console.log('Memory cleanup completed');
}
return true;
},
setDebugMode: function(enabled) {
config.debug = Boolean(enabled);
return `Debug mode ${config.debug ? 'enabled' : 'disabled'}`;
},
getStatus: function() {
return {
monitoring: {
enabled: Boolean(state.monitorInterval),
interval: config.monitorInterval
},
autoCleanup: {
enabled: Boolean(state.cleanupInterval),
interval: config.cleanupInterval
},
debug: config.debug
};
},
initialize: function(options = {}) {
if (options.debug !== undefined) {
this.setDebugMode(options.debug);
}
if (options.enableMonitoring) {
this.enableMonitoring(options.monitorInterval || config.monitorInterval);
}
if (options.enableAutoCleanup) {
this.enableAutoCleanup(options.cleanupInterval || config.cleanupInterval);
}
if (window.CleanupManager) {
window.CleanupManager.registerResource('memoryManager', this, (mgr) => mgr.dispose());
}
log('MemoryManager initialized');
return this;
},
dispose: function() {
this.disableMonitoring();
this.disableAutoCleanup();
log('MemoryManager disposed');
}
};
return publicAPI;
})();
window.MemoryManager = MemoryManager;
document.addEventListener('DOMContentLoaded', function() {
if (!window.memoryManagerInitialized) {
MemoryManager.initialize();
window.memoryManagerInitialized = true;
}
});
//console.log('MemoryManager initialized with methods:', Object.keys(MemoryManager));
console.log('MemoryManager initialized');
@@ -0,0 +1,280 @@
const NetworkManager = (function() {
const state = {
isOnline: navigator.onLine,
reconnectAttempts: 0,
reconnectTimer: null,
lastNetworkError: null,
eventHandlers: {},
connectionTestInProgress: false
};
const config = {
maxReconnectAttempts: 5,
reconnectDelay: 5000,
reconnectBackoff: 1.5,
connectionTestEndpoint: '/json',
connectionTestTimeout: 3000,
debug: false
};
function log(message, ...args) {
if (config.debug) {
console.log(`[NetworkManager] ${message}`, ...args);
}
}
function generateHandlerId() {
return `handler_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
const publicAPI = {
initialize: function(options = {}) {
Object.assign(config, options);
window.addEventListener('online', this.handleOnlineStatus.bind(this));
window.addEventListener('offline', this.handleOfflineStatus.bind(this));
state.isOnline = navigator.onLine;
log(`Network status initialized: ${state.isOnline ? 'online' : 'offline'}`);
if (window.CleanupManager) {
window.CleanupManager.registerResource('networkManager', this, (mgr) => mgr.dispose());
}
return this;
},
isOnline: function() {
return state.isOnline;
},
getReconnectAttempts: function() {
return state.reconnectAttempts;
},
resetReconnectAttempts: function() {
state.reconnectAttempts = 0;
return this;
},
handleOnlineStatus: function() {
log('Browser reports online status');
state.isOnline = true;
this.notifyHandlers('online');
if (state.reconnectTimer) {
this.scheduleReconnectRefresh();
}
},
handleOfflineStatus: function() {
log('Browser reports offline status');
state.isOnline = false;
this.notifyHandlers('offline');
},
handleNetworkError: function(error) {
if (error && (
(error.name === 'TypeError' && error.message.includes('NetworkError')) ||
(error.name === 'AbortError') ||
(error.message && error.message.includes('network')) ||
(error.message && error.message.includes('timeout'))
)) {
log('Network error detected:', error.message);
if (state.isOnline) {
state.isOnline = false;
state.lastNetworkError = error;
this.notifyHandlers('error', error);
}
if (!state.reconnectTimer) {
this.scheduleReconnectRefresh();
}
return true;
}
return false;
},
scheduleReconnectRefresh: function() {
if (state.reconnectTimer) {
clearTimeout(state.reconnectTimer);
state.reconnectTimer = null;
}
const delay = config.reconnectDelay * Math.pow(config.reconnectBackoff,
Math.min(state.reconnectAttempts, 5));
log(`Scheduling reconnection attempt in ${delay/1000} seconds`);
state.reconnectTimer = setTimeout(() => {
state.reconnectTimer = null;
this.attemptReconnect();
}, delay);
return this;
},
attemptReconnect: function() {
if (!navigator.onLine) {
log('Browser still reports offline, delaying reconnection attempt');
this.scheduleReconnectRefresh();
return;
}
if (state.connectionTestInProgress) {
log('Connection test already in progress');
return;
}
state.reconnectAttempts++;
state.connectionTestInProgress = true;
log(`Attempting reconnect #${state.reconnectAttempts}`);
this.testBackendConnection()
.then(isAvailable => {
state.connectionTestInProgress = false;
if (isAvailable) {
log('Backend connection confirmed');
state.isOnline = true;
state.reconnectAttempts = 0;
state.lastNetworkError = null;
this.notifyHandlers('reconnected');
} else {
log('Backend still unavailable');
if (state.reconnectAttempts < config.maxReconnectAttempts) {
this.scheduleReconnectRefresh();
} else {
log('Maximum reconnect attempts reached');
this.notifyHandlers('maxAttemptsReached');
}
}
})
.catch(error => {
state.connectionTestInProgress = false;
log('Error during connection test:', error);
if (state.reconnectAttempts < config.maxReconnectAttempts) {
this.scheduleReconnectRefresh();
} else {
log('Maximum reconnect attempts reached');
this.notifyHandlers('maxAttemptsReached');
}
});
},
testBackendConnection: function() {
return fetch(config.connectionTestEndpoint, {
method: 'HEAD',
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
},
timeout: config.connectionTestTimeout,
signal: AbortSignal.timeout(config.connectionTestTimeout)
})
.then(response => {
return response.ok;
})
.catch(error => {
log('Backend connection test failed:', error.message);
return false;
});
},
manualReconnect: function() {
log('Manual reconnection requested');
state.isOnline = navigator.onLine;
state.reconnectAttempts = 0;
this.notifyHandlers('manualReconnect');
if (state.isOnline) {
return this.attemptReconnect();
} else {
log('Cannot attempt manual reconnect while browser reports offline');
this.notifyHandlers('offlineWarning');
return false;
}
},
addHandler: function(event, handler) {
if (!state.eventHandlers[event]) {
state.eventHandlers[event] = {};
}
const handlerId = generateHandlerId();
state.eventHandlers[event][handlerId] = handler;
return handlerId;
},
removeHandler: function(event, handlerId) {
if (state.eventHandlers[event] && state.eventHandlers[event][handlerId]) {
delete state.eventHandlers[event][handlerId];
return true;
}
return false;
},
notifyHandlers: function(event, data) {
if (state.eventHandlers[event]) {
Object.values(state.eventHandlers[event]).forEach(handler => {
try {
handler(data);
} catch (error) {
log(`Error in ${event} handler:`, error);
}
});
}
},
setDebugMode: function(enabled) {
config.debug = Boolean(enabled);
return `Debug mode ${config.debug ? 'enabled' : 'disabled'}`;
},
getState: function() {
return {
isOnline: state.isOnline,
reconnectAttempts: state.reconnectAttempts,
hasReconnectTimer: Boolean(state.reconnectTimer),
connectionTestInProgress: state.connectionTestInProgress
};
},
dispose: function() {
if (state.reconnectTimer) {
clearTimeout(state.reconnectTimer);
state.reconnectTimer = null;
}
window.removeEventListener('online', this.handleOnlineStatus);
window.removeEventListener('offline', this.handleOfflineStatus);
state.eventHandlers = {};
log('NetworkManager disposed');
}
};
return publicAPI;
})();
window.NetworkManager = NetworkManager;
document.addEventListener('DOMContentLoaded', function() {
if (!window.networkManagerInitialized) {
NetworkManager.initialize();
window.networkManagerInitialized = true;
}
});
//console.log('NetworkManager initialized with methods:', Object.keys(NetworkManager));
console.log('NetworkManager initialized');
@@ -0,0 +1,126 @@
const NotificationManager = (function() {
const config = {
showNewOffers: false,
showNewBids: true,
showBidAccepted: true
};
function ensureToastContainer() {
let container = document.getElementById('ul_updates');
if (!container) {
const floating_div = document.createElement('div');
floating_div.classList.add('floatright');
container = document.createElement('ul');
container.setAttribute('id', 'ul_updates');
floating_div.appendChild(container);
document.body.appendChild(floating_div);
}
return container;
}
const publicAPI = {
initialize: function(options = {}) {
Object.assign(config, options);
if (window.CleanupManager) {
window.CleanupManager.registerResource('notificationManager', this, (mgr) => {
console.log('NotificationManager disposed');
});
}
return this;
},
createToast: function(title, type = 'success') {
const messages = ensureToastContainer();
const message = document.createElement('li');
message.innerHTML = `
<div id="hide">
<div id="toast-${type}" class="flex items-center p-4 mb-4 w-full max-w-xs text-gray-500
bg-white rounded-lg shadow" role="alert">
<div class="inline-flex flex-shrink-0 justify-center items-center w-10 h-10
bg-blue-500 rounded-lg">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" height="18" width="18"
viewBox="0 0 24 24">
<g fill="#ffffff">
<path d="M8.5,20a1.5,1.5,0,0,1-1.061-.439L.379,12.5,2.5,10.379l6,6,13-13L23.621,
5.5,9.561,19.561A1.5,1.5,0,0,1,8.5,20Z"></path>
</g>
</svg>
</div>
<div class="uppercase w-40 ml-3 text-sm font-semibold text-gray-900">${title}</div>
<button type="button" onclick="closeAlert(event)" class="ml-auto -mx-1.5 -my-1.5
bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-0 focus:outline-none
focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex h-8 w-8">
<span class="sr-only">Close</span>
<svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1
1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293
4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"></path>
</svg>
</button>
</div>
</div>
`;
messages.appendChild(message);
},
handleWebSocketEvent: function(data) {
if (!data || !data.event) return;
let toastTitle;
let shouldShowToast = false;
switch (data.event) {
case 'new_offer':
toastTitle = `New network <a class="underline" href=/offer/${data.offer_id}>offer</a>`;
shouldShowToast = config.showNewOffers;
break;
case 'new_bid':
toastTitle = `<a class="underline" href=/bid/${data.bid_id}>New bid</a> on
<a class="underline" href=/offer/${data.offer_id}>offer</a>`;
shouldShowToast = config.showNewBids;
break;
case 'bid_accepted':
toastTitle = `<a class="underline" href=/bid/${data.bid_id}>Bid</a> accepted`;
shouldShowToast = config.showBidAccepted;
break;
}
if (toastTitle && shouldShowToast) {
this.createToast(toastTitle);
}
},
updateConfig: function(newConfig) {
Object.assign(config, newConfig);
return this;
}
};
window.closeAlert = function(event) {
let element = event.target;
while (element.nodeName !== "BUTTON") {
element = element.parentNode;
}
element.parentNode.parentNode.removeChild(element.parentNode);
};
return publicAPI;
})();
window.NotificationManager = NotificationManager;
document.addEventListener('DOMContentLoaded', function() {
if (!window.notificationManagerInitialized) {
window.NotificationManager.initialize(window.notificationConfig || {});
window.notificationManagerInitialized = true;
}
});
//console.log('NotificationManager initialized with methods:', Object.keys(NotificationManager));
console.log('NotificationManager initialized');
@@ -0,0 +1,338 @@
const SummaryManager = (function() {
const config = {
refreshInterval: window.config?.cacheDuration || 30000,
summaryEndpoint: '/json',
retryDelay: 5000,
maxRetries: 3,
requestTimeout: 15000
};
let refreshTimer = null;
let webSocket = null;
let fetchRetryCount = 0;
let lastSuccessfulData = null;
function updateElement(elementId, value) {
const element = document.getElementById(elementId);
if (!element) return false;
const safeValue = (value !== undefined && value !== null)
? value
: (element.dataset.lastValue || 0);
element.dataset.lastValue = safeValue;
if (elementId === 'sent-bids-counter' || elementId === 'recv-bids-counter') {
const svg = element.querySelector('svg');
element.textContent = safeValue;
if (svg) {
element.insertBefore(svg, element.firstChild);
}
} else {
element.textContent = safeValue;
}
if (['offers-counter', 'bid-requests-counter', 'sent-bids-counter',
'recv-bids-counter', 'swaps-counter', 'network-offers-counter',
'watched-outputs-counter'].includes(elementId)) {
element.classList.remove('bg-blue-500', 'bg-gray-400');
element.classList.add(safeValue > 0 ? 'bg-blue-500' : 'bg-gray-400');
}
if (elementId === 'swaps-counter') {
const swapContainer = document.getElementById('swapContainer');
if (swapContainer) {
const isSwapping = safeValue > 0;
if (isSwapping) {
swapContainer.innerHTML = document.querySelector('#swap-in-progress-green-template').innerHTML || '';
swapContainer.style.animation = 'spin 2s linear infinite';
} else {
swapContainer.innerHTML = document.querySelector('#swap-in-progress-template').innerHTML || '';
swapContainer.style.animation = 'none';
}
}
}
return true;
}
function updateUIFromData(data) {
if (!data) return;
updateElement('network-offers-counter', data.num_network_offers);
updateElement('offers-counter', data.num_sent_active_offers);
updateElement('sent-bids-counter', data.num_sent_active_bids);
updateElement('recv-bids-counter', data.num_recv_active_bids);
updateElement('bid-requests-counter', data.num_available_bids);
updateElement('swaps-counter', data.num_swapping);
updateElement('watched-outputs-counter', data.num_watched_outputs);
const shutdownButtons = document.querySelectorAll('.shutdown-button');
shutdownButtons.forEach(button => {
button.setAttribute('data-active-swaps', data.num_swapping);
if (data.num_swapping > 0) {
button.classList.add('shutdown-disabled');
button.setAttribute('data-disabled', 'true');
button.setAttribute('title', 'Caution: Swaps in progress');
} else {
button.classList.remove('shutdown-disabled');
button.removeAttribute('data-disabled');
button.removeAttribute('title');
}
});
}
function cacheSummaryData(data) {
if (!data) return;
localStorage.setItem('summary_data_cache', JSON.stringify({
timestamp: Date.now(),
data: data
}));
}
function getCachedSummaryData() {
let cachedData = null;
cachedData = localStorage.getItem('summary_data_cache');
if (!cachedData) return null;
const parsedCache = JSON.parse(cachedData);
const maxAge = 24 * 60 * 60 * 1000;
if (Date.now() - parsedCache.timestamp < maxAge) {
return parsedCache.data;
}
return null;
}
function fetchSummaryDataWithTimeout() {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.requestTimeout);
return fetch(config.summaryEndpoint, {
signal: controller.signal,
headers: {
'Accept': 'application/json',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
})
.then(response => {
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
})
.catch(error => {
clearTimeout(timeoutId);
throw error;
});
}
function setupWebSocket() {
if (webSocket) {
webSocket.close();
}
const wsPort = window.config?.wsPort ||
(typeof determineWebSocketPort === 'function' ? determineWebSocketPort() : '11700');
const wsUrl = "ws://" + window.location.hostname + ":" + wsPort;
webSocket = new WebSocket(wsUrl);
webSocket.onopen = () => {
publicAPI.fetchSummaryData()
.then(() => {})
.catch(() => {});
};
webSocket.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch (error) {
if (window.logger && window.logger.error) {
window.logger.error('WebSocket message processing error: ' + error.message);
}
return;
}
if (data.event) {
publicAPI.fetchSummaryData()
.then(() => {})
.catch(() => {});
if (window.NotificationManager && typeof window.NotificationManager.handleWebSocketEvent === 'function') {
window.NotificationManager.handleWebSocketEvent(data);
}
}
};
webSocket.onclose = () => {
setTimeout(setupWebSocket, 5000);
};
}
function ensureSwapTemplates() {
if (!document.getElementById('swap-in-progress-template')) {
const template = document.createElement('template');
template.id = 'swap-in-progress-template';
template.innerHTML = document.querySelector('[id^="swapContainer"]')?.innerHTML || '';
document.body.appendChild(template);
}
if (!document.getElementById('swap-in-progress-green-template') &&
document.querySelector('[id^="swapContainer"]')?.innerHTML) {
const greenTemplate = document.createElement('template');
greenTemplate.id = 'swap-in-progress-green-template';
greenTemplate.innerHTML = document.querySelector('[id^="swapContainer"]')?.innerHTML || '';
document.body.appendChild(greenTemplate);
}
}
function startRefreshTimer() {
stopRefreshTimer();
publicAPI.fetchSummaryData()
.then(() => {})
.catch(() => {});
refreshTimer = setInterval(() => {
publicAPI.fetchSummaryData()
.then(() => {})
.catch(() => {});
}, config.refreshInterval);
}
function stopRefreshTimer() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
const publicAPI = {
initialize: function(options = {}) {
Object.assign(config, options);
ensureSwapTemplates();
const cachedData = getCachedSummaryData();
if (cachedData) {
updateUIFromData(cachedData);
}
if (window.WebSocketManager && typeof window.WebSocketManager.initialize === 'function') {
const wsManager = window.WebSocketManager;
if (!wsManager.isConnected()) {
wsManager.connect();
}
wsManager.addMessageHandler('message', (data) => {
if (data.event) {
this.fetchSummaryData()
.then(() => {})
.catch(() => {});
if (window.NotificationManager && typeof window.NotificationManager.handleWebSocketEvent === 'function') {
window.NotificationManager.handleWebSocketEvent(data);
}
}
});
} else {
setupWebSocket();
}
startRefreshTimer();
if (window.CleanupManager) {
window.CleanupManager.registerResource('summaryManager', this, (mgr) => mgr.dispose());
}
return this;
},
fetchSummaryData: function() {
return fetchSummaryDataWithTimeout()
.then(data => {
lastSuccessfulData = data;
cacheSummaryData(data);
fetchRetryCount = 0;
updateUIFromData(data);
return data;
})
.catch(error => {
if (window.logger && window.logger.error) {
window.logger.error('Summary data fetch error: ' + error.message);
}
if (fetchRetryCount < config.maxRetries) {
fetchRetryCount++;
if (window.logger && window.logger.warn) {
window.logger.warn(`Retrying summary data fetch (${fetchRetryCount}/${config.maxRetries}) in ${config.retryDelay/1000}s`);
}
return new Promise(resolve => {
setTimeout(() => {
resolve(this.fetchSummaryData());
}, config.retryDelay);
});
} else {
const cachedData = lastSuccessfulData || getCachedSummaryData();
if (cachedData) {
if (window.logger && window.logger.warn) {
window.logger.warn('Using cached summary data after fetch failures');
}
updateUIFromData(cachedData);
}
fetchRetryCount = 0;
throw error;
}
});
},
startRefreshTimer: function() {
startRefreshTimer();
},
stopRefreshTimer: function() {
stopRefreshTimer();
},
dispose: function() {
stopRefreshTimer();
if (webSocket && webSocket.readyState === WebSocket.OPEN) {
webSocket.close();
}
webSocket = null;
}
};
return publicAPI;
})();
window.SummaryManager = SummaryManager;
document.addEventListener('DOMContentLoaded', function() {
if (!window.summaryManagerInitialized) {
window.SummaryManager = SummaryManager.initialize();
window.summaryManagerInitialized = true;
}
});
//console.log('SummaryManager initialized with methods:', Object.keys(SummaryManager));
console.log('SummaryManager initialized');
@@ -0,0 +1,588 @@
const TooltipManager = (function() {
let instance = null;
class TooltipManagerImpl {
constructor() {
if (instance) {
return instance;
}
this.activeTooltips = new WeakMap();
this.tooltipIdCounter = 0;
this.pendingAnimationFrames = new Set();
this.tooltipElementsMap = new Map();
this.maxTooltips = 300;
this.cleanupThreshold = 1.3;
this.disconnectedCheckInterval = null;
this.setupStyles();
this.setupCleanupEvents();
this.initializeMutationObserver();
this.startDisconnectedElementsCheck();
instance = this;
}
create(element, content, options = {}) {
if (!element) return null;
this.destroy(element);
if (this.tooltipElementsMap.size > this.maxTooltips * this.cleanupThreshold) {
const oldestEntries = Array.from(this.tooltipElementsMap.entries())
.sort((a, b) => a[1].timestamp - b[1].timestamp)
.slice(0, 20);
oldestEntries.forEach(([el]) => {
this.destroy(el);
});
}
const originalContent = content;
const rafId = requestAnimationFrame(() => {
this.pendingAnimationFrames.delete(rafId);
if (!document.body.contains(element)) return;
const rect = element.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
this.createTooltip(element, originalContent, options, rect);
} else {
let retryCount = 0;
const retryCreate = () => {
const newRect = element.getBoundingClientRect();
if ((newRect.width > 0 && newRect.height > 0) || retryCount >= 3) {
if (newRect.width > 0 && newRect.height > 0) {
this.createTooltip(element, originalContent, options, newRect);
}
} else {
retryCount++;
const newRafId = requestAnimationFrame(retryCreate);
this.pendingAnimationFrames.add(newRafId);
}
};
const initialRetryId = requestAnimationFrame(retryCreate);
this.pendingAnimationFrames.add(initialRetryId);
}
});
this.pendingAnimationFrames.add(rafId);
return null;
}
createTooltip(element, content, options, rect) {
const targetId = element.getAttribute('data-tooltip-target');
let bgClass = 'bg-gray-400';
let arrowColor = 'rgb(156 163 175)';
if (targetId?.includes('tooltip-offer-') && window.jsonData) {
try {
const offerId = targetId.split('tooltip-offer-')[1];
let actualOfferId = offerId;
if (offerId.includes('_')) {
[actualOfferId] = offerId.split('_');
}
let offer = null;
if (Array.isArray(window.jsonData)) {
for (let i = 0; i < window.jsonData.length; i++) {
const o = window.jsonData[i];
if (o && (o.unique_id === offerId || o.offer_id === actualOfferId)) {
offer = o;
break;
}
}
}
if (offer) {
if (offer.is_revoked) {
bgClass = 'bg-red-500';
arrowColor = 'rgb(239 68 68)';
} else if (offer.is_own_offer) {
bgClass = 'bg-gray-300';
arrowColor = 'rgb(209 213 219)';
} else {
bgClass = 'bg-green-700';
arrowColor = 'rgb(21 128 61)';
}
}
} catch (e) {
console.warn('Error finding offer for tooltip:', e);
}
}
const tooltipId = `tooltip-${++this.tooltipIdCounter}`;
try {
if (typeof tippy !== 'function') {
console.error('Tippy.js is not loaded. Cannot create tooltip.');
return null;
}
const instance = tippy(element, {
content: content,
allowHTML: true,
placement: options.placement || 'top',
appendTo: document.body,
animation: false,
duration: 0,
delay: 0,
interactive: true,
arrow: false,
theme: '',
moveTransition: 'none',
offset: [0, 10],
onShow(instance) {
if (!document.body.contains(element)) {
return false;
}
return true;
},
onMount(instance) {
if (instance.popper && instance.popper.firstElementChild) {
instance.popper.firstElementChild.classList.add(bgClass);
instance.popper.setAttribute('data-for-tooltip-id', tooltipId);
}
const arrow = instance.popper.querySelector('.tippy-arrow');
if (arrow) {
arrow.style.setProperty('color', arrowColor, 'important');
}
},
popperOptions: {
strategy: 'fixed',
modifiers: [
{
name: 'preventOverflow',
options: {
boundary: 'viewport',
padding: 10
}
},
{
name: 'flip',
options: {
padding: 10,
fallbackPlacements: ['top', 'bottom', 'right', 'left']
}
}
]
}
});
element.setAttribute('data-tooltip-trigger-id', tooltipId);
this.activeTooltips.set(element, instance);
this.tooltipElementsMap.set(element, {
timestamp: Date.now(),
id: tooltipId
});
return instance;
} catch (e) {
console.error('Error creating tooltip:', e);
return null;
}
}
destroy(element) {
if (!element) return;
const id = element.getAttribute('data-tooltip-trigger-id');
if (!id) return;
const instance = this.activeTooltips.get(element);
if (instance?.[0]) {
try {
instance[0].destroy();
} catch (e) {
console.warn('Error destroying tooltip:', e);
const tippyRoot = document.querySelector(`[data-for-tooltip-id="${id}"]`);
if (tippyRoot && tippyRoot.parentNode) {
tippyRoot.parentNode.removeChild(tippyRoot);
}
}
}
this.activeTooltips.delete(element);
this.tooltipElementsMap.delete(element);
element.removeAttribute('data-tooltip-trigger-id');
}
cleanup() {
this.pendingAnimationFrames.forEach(id => {
cancelAnimationFrame(id);
});
this.pendingAnimationFrames.clear();
const elements = document.querySelectorAll('[data-tooltip-trigger-id]');
const batchSize = 20;
const processElementsBatch = (startIdx) => {
const endIdx = Math.min(startIdx + batchSize, elements.length);
for (let i = startIdx; i < endIdx; i++) {
this.destroy(elements[i]);
}
if (endIdx < elements.length) {
const rafId = requestAnimationFrame(() => {
this.pendingAnimationFrames.delete(rafId);
processElementsBatch(endIdx);
});
this.pendingAnimationFrames.add(rafId);
} else {
this.cleanupOrphanedTippyElements();
}
};
if (elements.length > 0) {
processElementsBatch(0);
} else {
this.cleanupOrphanedTippyElements();
}
this.tooltipElementsMap.clear();
}
cleanupOrphanedTippyElements() {
const tippyElements = document.querySelectorAll('[data-tippy-root]');
tippyElements.forEach(element => {
if (element.parentNode) {
element.parentNode.removeChild(element);
}
});
}
setupStyles() {
if (document.getElementById('tooltip-styles')) return;
document.head.insertAdjacentHTML('beforeend', `
<style id="tooltip-styles">
[data-tippy-root] {
position: fixed !important;
z-index: 9999 !important;
pointer-events: none !important;
}
.tippy-box {
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 500;
border-radius: 0.5rem;
color: white;
position: relative !important;
pointer-events: auto !important;
}
.tippy-content {
padding: 0.5rem 0.75rem !important;
}
.tippy-box .bg-gray-400 {
background-color: rgb(156 163 175);
padding: 0.5rem 0.75rem;
}
.tippy-box:has(.bg-gray-400) .tippy-arrow {
color: rgb(156 163 175);
}
.tippy-box .bg-red-500 {
background-color: rgb(239 68 68);
padding: 0.5rem 0.75rem;
}
.tippy-box:has(.bg-red-500) .tippy-arrow {
color: rgb(239 68 68);
}
.tippy-box .bg-gray-300 {
background-color: rgb(209 213 219);
padding: 0.5rem 0.75rem;
}
.tippy-box:has(.bg-gray-300) .tippy-arrow {
color: rgb(209 213 219);
}
.tippy-box .bg-green-700 {
background-color: rgb(21 128 61);
padding: 0.5rem 0.75rem;
}
.tippy-box:has(.bg-green-700) .tippy-arrow {
color: rgb(21 128 61);
}
.tippy-box[data-placement^='top'] > .tippy-arrow::before {
border-top-color: currentColor;
}
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: currentColor;
}
.tippy-box[data-placement^='left'] > .tippy-arrow::before {
border-left-color: currentColor;
}
.tippy-box[data-placement^='right'] > .tippy-arrow::before {
border-right-color: currentColor;
}
.tippy-box[data-placement^='top'] > .tippy-arrow {
bottom: 0;
}
.tippy-box[data-placement^='bottom'] > .tippy-arrow {
top: 0;
}
.tippy-box[data-placement^='left'] > .tippy-arrow {
right: 0;
}
.tippy-box[data-placement^='right'] > .tippy-arrow {
left: 0;
}
</style>
`);
}
setupCleanupEvents() {
this.boundCleanup = this.cleanup.bind(this);
this.handleVisibilityChange = () => {
if (document.hidden) {
this.cleanup();
if (window.MemoryManager) {
window.MemoryManager.forceCleanup();
}
}
};
window.addEventListener('beforeunload', this.boundCleanup);
window.addEventListener('unload', this.boundCleanup);
document.addEventListener('visibilitychange', this.handleVisibilityChange);
if (window.CleanupManager) {
window.CleanupManager.registerResource('tooltipManager', this, (tm) => tm.dispose());
}
this.cleanupInterval = setInterval(() => {
this.performPeriodicCleanup();
}, 120000);
}
startDisconnectedElementsCheck() {
if (this.disconnectedCheckInterval) {
clearInterval(this.disconnectedCheckInterval);
}
this.disconnectedCheckInterval = setInterval(() => {
this.checkForDisconnectedElements();
}, 60000);
}
checkForDisconnectedElements() {
if (this.tooltipElementsMap.size === 0) return;
const elementsToCheck = Array.from(this.tooltipElementsMap.keys());
let removedCount = 0;
elementsToCheck.forEach(element => {
if (!document.body.contains(element)) {
this.destroy(element);
removedCount++;
}
});
if (removedCount > 0) {
this.cleanupOrphanedTippyElements();
}
}
performPeriodicCleanup() {
this.cleanupOrphanedTippyElements();
this.checkForDisconnectedElements();
if (this.tooltipElementsMap.size > this.maxTooltips * this.cleanupThreshold) {
const sortedTooltips = Array.from(this.tooltipElementsMap.entries())
.sort((a, b) => a[1].timestamp - b[1].timestamp);
const tooltipsToRemove = sortedTooltips.slice(0, sortedTooltips.length - this.maxTooltips);
tooltipsToRemove.forEach(([element]) => {
this.destroy(element);
});
}
}
removeCleanupEvents() {
window.removeEventListener('beforeunload', this.boundCleanup);
window.removeEventListener('unload', this.boundCleanup);
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
if (this.disconnectedCheckInterval) {
clearInterval(this.disconnectedCheckInterval);
this.disconnectedCheckInterval = null;
}
}
initializeMutationObserver() {
if (this.mutationObserver) return;
this.mutationObserver = new MutationObserver(mutations => {
let needsCleanup = false;
mutations.forEach(mutation => {
if (mutation.removedNodes.length) {
Array.from(mutation.removedNodes).forEach(node => {
if (node.nodeType === 1) {
if (node.hasAttribute && node.hasAttribute('data-tooltip-trigger-id')) {
this.destroy(node);
needsCleanup = true;
}
if (node.querySelectorAll) {
const tooltipTriggers = node.querySelectorAll('[data-tooltip-trigger-id]');
if (tooltipTriggers.length > 0) {
tooltipTriggers.forEach(el => {
this.destroy(el);
});
needsCleanup = true;
}
}
}
});
}
});
if (needsCleanup) {
this.cleanupOrphanedTippyElements();
}
});
this.mutationObserver.observe(document.body, {
childList: true,
subtree: true
});
}
initializeTooltips(selector = '[data-tooltip-target]') {
document.querySelectorAll(selector).forEach(element => {
const targetId = element.getAttribute('data-tooltip-target');
const tooltipContent = document.getElementById(targetId);
if (tooltipContent) {
this.create(element, tooltipContent.innerHTML, {
placement: element.getAttribute('data-tooltip-placement') || 'top'
});
}
});
}
dispose() {
this.cleanup();
this.pendingAnimationFrames.forEach(id => {
cancelAnimationFrame(id);
});
this.pendingAnimationFrames.clear();
if (this.mutationObserver) {
this.mutationObserver.disconnect();
this.mutationObserver = null;
}
this.removeCleanupEvents();
const styleElement = document.getElementById('tooltip-styles');
if (styleElement && styleElement.parentNode) {
styleElement.parentNode.removeChild(styleElement);
}
this.activeTooltips = new WeakMap();
this.tooltipElementsMap.clear();
instance = null;
}
initialize(options = {}) {
if (options.maxTooltips) {
this.maxTooltips = options.maxTooltips;
}
console.log('TooltipManager initialized');
return this;
}
}
return {
initialize: function(options = {}) {
if (!instance) {
const manager = new TooltipManagerImpl();
manager.initialize(options);
}
return instance;
},
getInstance: function() {
if (!instance) {
const manager = new TooltipManagerImpl();
}
return instance;
},
create: function(...args) {
const manager = this.getInstance();
return manager.create(...args);
},
destroy: function(...args) {
const manager = this.getInstance();
return manager.destroy(...args);
},
cleanup: function(...args) {
const manager = this.getInstance();
return manager.cleanup(...args);
},
initializeTooltips: function(...args) {
const manager = this.getInstance();
return manager.initializeTooltips(...args);
},
dispose: function(...args) {
const manager = this.getInstance();
return manager.dispose(...args);
}
};
})();
window.TooltipManager = TooltipManager;
document.addEventListener('DOMContentLoaded', function() {
if (!window.tooltipManagerInitialized) {
TooltipManager.initialize();
TooltipManager.initializeTooltips();
window.tooltipManagerInitialized = true;
}
});
if (typeof module !== 'undefined' && module.exports) {
module.exports = TooltipManager;
}
//console.log('TooltipManager initialized with methods:', Object.keys(TooltipManager));
console.log('TooltipManager initialized');
@@ -0,0 +1,655 @@
const WalletManager = (function() {
const config = {
maxRetries: 5,
baseDelay: 500,
cacheExpiration: 5 * 60 * 1000,
priceUpdateInterval: 5 * 60 * 1000,
apiTimeout: 30000,
debounceDelay: 300,
cacheMinInterval: 60 * 1000,
defaultTTL: 300,
priceSource: {
primary: 'coingecko.com',
fallback: 'cryptocompare.com',
enabledSources: ['coingecko.com', 'cryptocompare.com']
}
};
const stateKeys = {
lastUpdate: 'last-update-time',
previousTotal: 'previous-total-usd',
currentTotal: 'current-total-usd',
balancesVisible: 'balancesVisible'
};
const coinData = {
symbols: {
'Bitcoin': 'BTC',
'Particl': 'PART',
'Monero': 'XMR',
'Wownero': 'WOW',
'Litecoin': 'LTC',
'Dogecoin': 'DOGE',
'Firo': 'FIRO',
'Dash': 'DASH',
'PIVX': 'PIVX',
'Decred': 'DCR',
'Bitcoin Cash': 'BCH'
},
coingeckoIds: {
'BTC': 'btc',
'PART': 'part',
'XMR': 'xmr',
'WOW': 'wownero',
'LTC': 'ltc',
'DOGE': 'doge',
'FIRO': 'firo',
'DASH': 'dash',
'PIVX': 'pivx',
'DCR': 'dcr',
'BCH': 'bch'
},
shortNames: {
'Bitcoin': 'BTC',
'Particl': 'PART',
'Monero': 'XMR',
'Wownero': 'WOW',
'Litecoin': 'LTC',
'Litecoin MWEB': 'LTC MWEB',
'Firo': 'FIRO',
'Dash': 'DASH',
'PIVX': 'PIVX',
'Decred': 'DCR',
'Bitcoin Cash': 'BCH',
'Dogecoin': 'DOGE'
}
};
const state = {
lastFetchTime: 0,
toggleInProgress: false,
toggleDebounceTimer: null,
priceUpdateInterval: null,
lastUpdateTime: 0,
isWalletsPage: false,
initialized: false,
cacheKey: 'rates_crypto_prices'
};
function getShortName(fullName) {
return coinData.shortNames[fullName] || fullName;
}
async function fetchPrices(forceUpdate = false) {
const now = Date.now();
const timeSinceLastFetch = now - state.lastFetchTime;
if (!forceUpdate && timeSinceLastFetch < config.cacheMinInterval) {
const cachedData = CacheManager.get(state.cacheKey);
if (cachedData) {
return cachedData.value;
}
}
let lastError = null;
for (let attempt = 0; attempt < config.maxRetries; attempt++) {
try {
const processedData = {};
const currentSource = config.priceSource.primary;
const shouldIncludeWow = currentSource === 'coingecko.com';
const coinsToFetch = Object.values(coinData.symbols)
.filter(symbol => shouldIncludeWow || symbol !== 'WOW')
.map(symbol => coinData.coingeckoIds[symbol] || symbol.toLowerCase())
.join(',');
const mainResponse = await fetch("/json/coinprices", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
coins: coinsToFetch,
source: currentSource,
ttl: config.defaultTTL
})
});
if (!mainResponse.ok) {
throw new Error(`HTTP error: ${mainResponse.status}`);
}
const mainData = await mainResponse.json();
if (mainData && mainData.rates) {
Object.entries(mainData.rates).forEach(([coinId, price]) => {
const symbol = Object.entries(coinData.coingeckoIds).find(([sym, id]) => id.toLowerCase() === coinId.toLowerCase())?.[0];
if (symbol) {
const coinKey = Object.keys(coinData.symbols).find(key => coinData.symbols[key] === symbol);
if (coinKey) {
processedData[coinKey.toLowerCase().replace(' ', '-')] = {
usd: price,
btc: symbol === 'BTC' ? 1 : price / (mainData.rates.btc || 1)
};
}
}
});
}
if (!shouldIncludeWow && !processedData['wownero']) {
try {
const wowResponse = await fetch("/json/coinprices", {
method: "POST",
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
coins: "wownero",
source: "coingecko.com",
ttl: config.defaultTTL
})
});
if (wowResponse.ok) {
const wowData = await wowResponse.json();
if (wowData && wowData.rates && wowData.rates.wownero) {
processedData['wownero'] = {
usd: wowData.rates.wownero,
btc: processedData.bitcoin ? wowData.rates.wownero / processedData.bitcoin.usd : 0
};
}
}
} catch (wowError) {
console.error('Error fetching WOW price:', wowError);
}
}
CacheManager.set(state.cacheKey, processedData, config.cacheExpiration);
state.lastFetchTime = now;
return processedData;
} catch (error) {
lastError = error;
console.error(`Price fetch attempt ${attempt + 1} failed:`, error);
if (attempt === config.maxRetries - 1 &&
config.priceSource.fallback &&
config.priceSource.fallback !== config.priceSource.primary) {
const temp = config.priceSource.primary;
config.priceSource.primary = config.priceSource.fallback;
config.priceSource.fallback = temp;
console.warn(`Switching to fallback source: ${config.priceSource.primary}`);
attempt = -1;
continue;
}
if (attempt < config.maxRetries - 1) {
const delay = Math.min(config.baseDelay * Math.pow(2, attempt), 10000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
const cachedData = CacheManager.get(state.cacheKey);
if (cachedData) {
console.warn('Using cached data after fetch failures');
return cachedData.value;
}
throw lastError || new Error('Failed to fetch prices');
}
// UI Management functions
function storeOriginalValues() {
document.querySelectorAll('.coinname-value').forEach(el => {
const coinName = el.getAttribute('data-coinname');
const value = el.textContent?.trim() || '';
if (coinName) {
const amount = value ? parseFloat(value.replace(/[^0-9.-]+/g, '')) : 0;
const coinId = coinData.symbols[coinName];
const shortName = getShortName(coinName);
if (coinId) {
if (coinName === 'Particl') {
const isBlind = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('Blind');
const isAnon = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('Anon');
const balanceType = isBlind ? 'blind' : isAnon ? 'anon' : 'public';
localStorage.setItem(`particl-${balanceType}-amount`, amount.toString());
} else if (coinName === 'Litecoin') {
const isMWEB = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('MWEB');
const balanceType = isMWEB ? 'mweb' : 'public';
localStorage.setItem(`litecoin-${balanceType}-amount`, amount.toString());
} else {
localStorage.setItem(`${coinId.toLowerCase()}-amount`, amount.toString());
}
el.setAttribute('data-original-value', `${amount} ${shortName}`);
}
}
});
document.querySelectorAll('.usd-value').forEach(el => {
const text = el.textContent?.trim() || '';
if (text === 'Loading...') {
el.textContent = '';
}
});
}
async function updatePrices(forceUpdate = false) {
try {
const prices = await fetchPrices(forceUpdate);
let newTotal = 0;
const currentTime = Date.now();
localStorage.setItem(stateKeys.lastUpdate, currentTime.toString());
state.lastUpdateTime = currentTime;
if (prices) {
Object.entries(prices).forEach(([coinId, priceData]) => {
if (priceData?.usd) {
localStorage.setItem(`${coinId}-price`, priceData.usd.toString());
}
});
}
document.querySelectorAll('.coinname-value').forEach(el => {
const coinName = el.getAttribute('data-coinname');
const amountStr = el.getAttribute('data-original-value') || el.textContent?.trim() || '';
if (!coinName) return;
let amount = 0;
if (amountStr) {
const matches = amountStr.match(/([0-9]*[.])?[0-9]+/);
if (matches && matches.length > 0) {
amount = parseFloat(matches[0]);
}
}
const coinId = coinName.toLowerCase().replace(' ', '-');
if (!prices[coinId]) {
return;
}
const price = prices[coinId]?.usd || parseFloat(localStorage.getItem(`${coinId}-price`) || '0');
if (!price) return;
const usdValue = (amount * price).toFixed(2);
if (coinName === 'Particl') {
const isBlind = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('Blind');
const isAnon = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('Anon');
const balanceType = isBlind ? 'blind' : isAnon ? 'anon' : 'public';
localStorage.setItem(`particl-${balanceType}-last-value`, usdValue);
localStorage.setItem(`particl-${balanceType}-amount`, amount.toString());
} else if (coinName === 'Litecoin') {
const isMWEB = el.closest('.flex')?.querySelector('h4')?.textContent?.includes('MWEB');
const balanceType = isMWEB ? 'mweb' : 'public';
localStorage.setItem(`litecoin-${balanceType}-last-value`, usdValue);
localStorage.setItem(`litecoin-${balanceType}-amount`, amount.toString());
} else {
localStorage.setItem(`${coinId}-last-value`, usdValue);
localStorage.setItem(`${coinId}-amount`, amount.toString());
}
if (amount > 0) {
newTotal += parseFloat(usdValue);
}
let usdEl = null;
const flexContainer = el.closest('.flex');
if (flexContainer) {
const nextFlex = flexContainer.nextElementSibling;
if (nextFlex) {
const usdInNextFlex = nextFlex.querySelector('.usd-value');
if (usdInNextFlex) {
usdEl = usdInNextFlex;
}
}
}
if (!usdEl) {
const parentCell = el.closest('td');
if (parentCell) {
const usdInSameCell = parentCell.querySelector('.usd-value');
if (usdInSameCell) {
usdEl = usdInSameCell;
}
}
}
if (!usdEl) {
const sibling = el.nextElementSibling;
if (sibling && sibling.classList.contains('usd-value')) {
usdEl = sibling;
}
}
if (!usdEl) {
const parentElement = el.parentElement;
if (parentElement) {
const usdElNearby = parentElement.querySelector('.usd-value');
if (usdElNearby) {
usdEl = usdElNearby;
}
}
}
if (usdEl) {
usdEl.textContent = `$${usdValue}`;
usdEl.setAttribute('data-original-value', usdValue);
}
});
document.querySelectorAll('.usd-value').forEach(el => {
if (el.closest('tr')?.querySelector('td')?.textContent?.includes('Fee Estimate:')) {
const parentCell = el.closest('td');
if (!parentCell) return;
const coinValueEl = parentCell.querySelector('.coinname-value');
if (!coinValueEl) return;
const coinName = coinValueEl.getAttribute('data-coinname');
if (!coinName) return;
const amountStr = coinValueEl.textContent?.trim() || '0';
const amount = parseFloat(amountStr) || 0;
const coinId = coinName.toLowerCase().replace(' ', '-');
if (!prices[coinId]) return;
const price = prices[coinId]?.usd || parseFloat(localStorage.getItem(`${coinId}-price`) || '0');
if (!price) return;
const usdValue = (amount * price).toFixed(8);
el.textContent = `$${usdValue}`;
el.setAttribute('data-original-value', usdValue);
}
});
if (state.isWalletsPage) {
updateTotalValues(newTotal, prices?.bitcoin?.usd);
}
localStorage.setItem(stateKeys.previousTotal, localStorage.getItem(stateKeys.currentTotal) || '0');
localStorage.setItem(stateKeys.currentTotal, newTotal.toString());
return true;
} catch (error) {
console.error('Price update failed:', error);
return false;
}
}
function updateTotalValues(totalUsd, btcPrice) {
const totalUsdEl = document.getElementById('total-usd-value');
if (totalUsdEl) {
totalUsdEl.textContent = `$${totalUsd.toFixed(2)}`;
totalUsdEl.setAttribute('data-original-value', totalUsd.toString());
localStorage.setItem('total-usd', totalUsd.toString());
}
if (btcPrice) {
const btcTotal = btcPrice ? totalUsd / btcPrice : 0;
const totalBtcEl = document.getElementById('total-btc-value');
if (totalBtcEl) {
totalBtcEl.textContent = `~ ${btcTotal.toFixed(8)} BTC`;
totalBtcEl.setAttribute('data-original-value', btcTotal.toString());
}
}
}
async function toggleBalances() {
if (state.toggleInProgress) return;
try {
state.toggleInProgress = true;
const balancesVisible = localStorage.getItem('balancesVisible') === 'true';
const newVisibility = !balancesVisible;
localStorage.setItem('balancesVisible', newVisibility.toString());
updateVisibility(newVisibility);
if (state.toggleDebounceTimer) {
clearTimeout(state.toggleDebounceTimer);
}
state.toggleDebounceTimer = window.setTimeout(async () => {
state.toggleInProgress = false;
if (newVisibility) {
await updatePrices(true);
}
}, config.debounceDelay);
} catch (error) {
console.error('Failed to toggle balances:', error);
state.toggleInProgress = false;
}
}
function updateVisibility(isVisible) {
if (isVisible) {
showBalances();
} else {
hideBalances();
}
const eyeIcon = document.querySelector("#hide-usd-amount-toggle svg");
if (eyeIcon) {
eyeIcon.innerHTML = isVisible ?
'<path d="M23.444,10.239C21.905,8.062,17.708,3,12,3S2.1,8.062,.555,10.24a3.058,3.058,0,0,0,0,3.52h0C2.1,15.938,6.292,21,12,21s9.905-5.062,11.445-7.24A3.058,3.058,0,0,0,23.444,10.239ZM12,17a5,5,0,1,1,5-5A5,5,0,0,1,12,17Z"></path>' :
'<path d="M23.444,10.239a22.936,22.936,0,0,0-2.492-2.948l-4.021,4.021A5.026,5.026,0,0,1,17,12a5,5,0,0,1-5,5,5.026,5.026,0,0,1-.688-.069L8.055,20.188A10.286,10.286,0,0,0,12,21c5.708,0,9.905-5.062,11.445-7.24A3.058,3.058,0,0,0,23.444,10.239Z"></path><path d="M12,3C6.292,3,2.1,8.062,.555,10.24a3.058,3.058,0,0,0,0,3.52h0a21.272,21.272,0,0,0,4.784,4.9l3.124-3.124a5,5,0,0,1,7.071-7.072L8.464,15.536l10.2-10.2A11.484,11.484,0,0,0,12,3Z"></path><path data-color="color-2" d="M1,24a1,1,0,0,1-.707-1.707l22-22a1,1,0,0,1,1.414,1.414l-22,22A1,1,0,0,1,1,24Z"></path>';
}
}
function showBalances() {
const usdText = document.getElementById('usd-text');
if (usdText) {
usdText.style.display = 'inline';
}
document.querySelectorAll('.coinname-value').forEach(el => {
const originalValue = el.getAttribute('data-original-value');
if (originalValue) {
el.textContent = originalValue;
}
});
document.querySelectorAll('.usd-value').forEach(el => {
const storedValue = el.getAttribute('data-original-value');
if (storedValue !== null && storedValue !== undefined) {
if (el.closest('tr')?.querySelector('td')?.textContent?.includes('Fee Estimate:')) {
el.textContent = `$${parseFloat(storedValue).toFixed(8)}`;
} else {
el.textContent = `$${parseFloat(storedValue).toFixed(2)}`;
}
} else {
if (el.closest('tr')?.querySelector('td')?.textContent?.includes('Fee Estimate:')) {
el.textContent = '$0.00000000';
} else {
el.textContent = '$0.00';
}
}
});
if (state.isWalletsPage) {
['total-usd-value', 'total-btc-value'].forEach(id => {
const el = document.getElementById(id);
const originalValue = el?.getAttribute('data-original-value');
if (el && originalValue) {
if (id === 'total-usd-value') {
el.textContent = `$${parseFloat(originalValue).toFixed(2)}`;
el.classList.add('font-extrabold');
} else {
el.textContent = `~ ${parseFloat(originalValue).toFixed(8)} BTC`;
}
}
});
}
}
function hideBalances() {
const usdText = document.getElementById('usd-text');
if (usdText) {
usdText.style.display = 'none';
}
document.querySelectorAll('.coinname-value').forEach(el => {
el.textContent = '****';
});
document.querySelectorAll('.usd-value').forEach(el => {
el.textContent = '****';
});
if (state.isWalletsPage) {
['total-usd-value', 'total-btc-value'].forEach(id => {
const el = document.getElementById(id);
if (el) {
el.textContent = '****';
}
});
const totalUsdEl = document.getElementById('total-usd-value');
if (totalUsdEl) {
totalUsdEl.classList.remove('font-extrabold');
}
}
}
async function loadBalanceVisibility() {
const balancesVisible = localStorage.getItem('balancesVisible') === 'true';
updateVisibility(balancesVisible);
if (balancesVisible) {
await updatePrices(true);
}
}
// Public API
const publicAPI = {
initialize: async function(options) {
if (state.initialized) {
console.warn('[WalletManager] Already initialized');
return this;
}
if (options) {
Object.assign(config, options);
}
state.lastUpdateTime = parseInt(localStorage.getItem(stateKeys.lastUpdate) || '0');
state.isWalletsPage = document.querySelector('.wallet-list') !== null ||
window.location.pathname.includes('/wallets');
document.querySelectorAll('.usd-value').forEach(el => {
const text = el.textContent?.trim() || '';
if (text === 'Loading...') {
el.textContent = '';
}
});
storeOriginalValues();
if (localStorage.getItem('balancesVisible') === null) {
localStorage.setItem('balancesVisible', 'true');
}
const hideBalancesToggle = document.getElementById('hide-usd-amount-toggle');
if (hideBalancesToggle) {
hideBalancesToggle.addEventListener('click', toggleBalances);
}
await loadBalanceVisibility();
if (state.priceUpdateInterval) {
clearInterval(state.priceUpdateInterval);
}
state.priceUpdateInterval = setInterval(() => {
if (localStorage.getItem('balancesVisible') === 'true' && !state.toggleInProgress) {
updatePrices(false);
}
}, config.priceUpdateInterval);
if (window.CleanupManager) {
window.CleanupManager.registerResource('walletManager', this, (mgr) => mgr.dispose());
}
state.initialized = true;
console.log('WalletManager initialized');
return this;
},
updatePrices: function(forceUpdate = false) {
return updatePrices(forceUpdate);
},
toggleBalances: function() {
return toggleBalances();
},
setPriceSource: function(primarySource, fallbackSource = null) {
if (!config.priceSource.enabledSources.includes(primarySource)) {
throw new Error(`Invalid primary source: ${primarySource}`);
}
if (fallbackSource && !config.priceSource.enabledSources.includes(fallbackSource)) {
throw new Error(`Invalid fallback source: ${fallbackSource}`);
}
config.priceSource.primary = primarySource;
if (fallbackSource) {
config.priceSource.fallback = fallbackSource;
}
return this;
},
getConfig: function() {
return { ...config };
},
getState: function() {
return {
initialized: state.initialized,
lastUpdateTime: state.lastUpdateTime,
isWalletsPage: state.isWalletsPage,
balancesVisible: localStorage.getItem('balancesVisible') === 'true'
};
},
dispose: function() {
if (state.priceUpdateInterval) {
clearInterval(state.priceUpdateInterval);
state.priceUpdateInterval = null;
}
if (state.toggleDebounceTimer) {
clearTimeout(state.toggleDebounceTimer);
state.toggleDebounceTimer = null;
}
state.initialized = false;
console.log('WalletManager disposed');
}
};
return publicAPI;
})();
window.WalletManager = WalletManager;
document.addEventListener('DOMContentLoaded', function() {
if (!window.walletManagerInitialized) {
WalletManager.initialize();
window.walletManagerInitialized = true;
}
});
//console.log('WalletManager initialized with methods:', Object.keys(WalletManager));
console.log('WalletManager initialized');
@@ -0,0 +1,444 @@
const WebSocketManager = (function() {
let ws = null;
const config = {
reconnectAttempts: 0,
maxReconnectAttempts: 5,
reconnectDelay: 5000,
debug: false
};
const state = {
isConnecting: false,
isIntentionallyClosed: false,
lastConnectAttempt: null,
connectTimeout: null,
lastHealthCheck: null,
healthCheckInterval: null,
isPageHidden: document.hidden,
messageHandlers: {},
listeners: {},
reconnectTimeout: null
};
function log(message, ...args) {
if (config.debug) {
console.log(`[WebSocketManager] ${message}`, ...args);
}
}
function generateHandlerId() {
return `handler_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
function determineWebSocketPort() {
let wsPort;
if (window.config && window.config.wsPort) {
wsPort = window.config.wsPort;
return wsPort;
}
if (window.ws_port) {
wsPort = window.ws_port.toString();
return wsPort;
}
if (typeof getWebSocketConfig === 'function') {
const wsConfig = getWebSocketConfig();
wsPort = (wsConfig.port || wsConfig.fallbackPort || '11700').toString();
return wsPort;
}
wsPort = '11700';
return wsPort;
}
const publicAPI = {
initialize: function(options = {}) {
Object.assign(config, options);
setupPageVisibilityHandler();
this.connect();
startHealthCheck();
log('WebSocketManager initialized with options:', options);
if (window.CleanupManager) {
window.CleanupManager.registerResource('webSocketManager', this, (mgr) => mgr.dispose());
}
return this;
},
connect: function() {
if (state.isConnecting || state.isIntentionallyClosed) {
log('Connection attempt blocked - already connecting or intentionally closed');
return false;
}
if (state.reconnectTimeout) {
clearTimeout(state.reconnectTimeout);
state.reconnectTimeout = null;
}
cleanup();
state.isConnecting = true;
state.lastConnectAttempt = Date.now();
try {
const wsPort = determineWebSocketPort();
if (!wsPort) {
state.isConnecting = false;
return false;
}
ws = new WebSocket(`ws://${window.location.hostname}:${wsPort}`);
setupEventHandlers();
state.connectTimeout = setTimeout(() => {
if (state.isConnecting) {
log('Connection timeout, cleaning up');
cleanup();
handleReconnect();
}
}, 5000);
return true;
} catch (error) {
log('Error during connection attempt:', error);
state.isConnecting = false;
handleReconnect();
return false;
}
},
disconnect: function() {
log('Disconnecting WebSocket');
state.isIntentionallyClosed = true;
cleanup();
stopHealthCheck();
},
isConnected: function() {
return ws && ws.readyState === WebSocket.OPEN;
},
sendMessage: function(message) {
if (!this.isConnected()) {
log('Cannot send message - not connected');
return false;
}
try {
ws.send(JSON.stringify(message));
return true;
} catch (error) {
log('Error sending message:', error);
return false;
}
},
addMessageHandler: function(type, handler) {
if (!state.messageHandlers[type]) {
state.messageHandlers[type] = {};
}
const handlerId = generateHandlerId();
state.messageHandlers[type][handlerId] = handler;
return handlerId;
},
removeMessageHandler: function(type, handlerId) {
if (state.messageHandlers[type] && state.messageHandlers[type][handlerId]) {
delete state.messageHandlers[type][handlerId];
}
},
cleanup: function() {
log('Cleaning up WebSocket resources');
clearTimeout(state.connectTimeout);
stopHealthCheck();
if (state.reconnectTimeout) {
clearTimeout(state.reconnectTimeout);
state.reconnectTimeout = null;
}
state.isConnecting = false;
if (ws) {
ws.onopen = null;
ws.onmessage = null;
ws.onerror = null;
ws.onclose = null;
if (ws.readyState === WebSocket.OPEN) {
ws.close(1000, 'Cleanup');
}
ws = null;
window.ws = null;
}
},
dispose: function() {
log('Disposing WebSocketManager');
this.disconnect();
if (state.listeners.visibilityChange) {
document.removeEventListener('visibilitychange', state.listeners.visibilityChange);
}
state.messageHandlers = {};
state.listeners = {};
},
pause: function() {
log('WebSocketManager paused');
state.isIntentionallyClosed = true;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close(1000, 'WebSocketManager paused');
}
stopHealthCheck();
},
resume: function() {
log('WebSocketManager resumed');
state.isIntentionallyClosed = false;
if (!this.isConnected()) {
this.connect();
}
startHealthCheck();
}
};
function setupEventHandlers() {
if (!ws) return;
ws.onopen = () => {
state.isConnecting = false;
config.reconnectAttempts = 0;
clearTimeout(state.connectTimeout);
state.lastHealthCheck = Date.now();
window.ws = ws;
log('WebSocket connection established');
notifyHandlers('connect', { isConnected: true });
if (typeof updateConnectionStatus === 'function') {
updateConnectionStatus('connected');
}
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
log('WebSocket message received:', message);
notifyHandlers('message', message);
} catch (error) {
log('Error processing message:', error);
if (typeof updateConnectionStatus === 'function') {
updateConnectionStatus('error');
}
}
};
ws.onerror = (error) => {
log('WebSocket error:', error);
if (typeof updateConnectionStatus === 'function') {
updateConnectionStatus('error');
}
notifyHandlers('error', error);
};
ws.onclose = (event) => {
log('WebSocket closed:', event);
state.isConnecting = false;
window.ws = null;
if (typeof updateConnectionStatus === 'function') {
updateConnectionStatus('disconnected');
}
notifyHandlers('disconnect', {
code: event.code,
reason: event.reason
});
if (!state.isIntentionallyClosed) {
handleReconnect();
}
};
}
function setupPageVisibilityHandler() {
const visibilityChangeHandler = () => {
if (document.hidden) {
handlePageHidden();
} else {
handlePageVisible();
}
};
document.addEventListener('visibilitychange', visibilityChangeHandler);
state.listeners.visibilityChange = visibilityChangeHandler;
}
function handlePageHidden() {
log('Page hidden');
state.isPageHidden = true;
stopHealthCheck();
if (ws && ws.readyState === WebSocket.OPEN) {
state.isIntentionallyClosed = true;
ws.close(1000, 'Page hidden');
}
}
function handlePageVisible() {
log('Page visible');
state.isPageHidden = false;
state.isIntentionallyClosed = false;
setTimeout(() => {
if (!publicAPI.isConnected()) {
publicAPI.connect();
}
startHealthCheck();
}, 0);
}
function startHealthCheck() {
stopHealthCheck();
state.healthCheckInterval = setInterval(() => {
performHealthCheck();
}, 30000);
}
function stopHealthCheck() {
if (state.healthCheckInterval) {
clearInterval(state.healthCheckInterval);
state.healthCheckInterval = null;
}
}
function performHealthCheck() {
if (!publicAPI.isConnected()) {
log('Health check failed - not connected');
handleReconnect();
return;
}
const now = Date.now();
const lastCheck = state.lastHealthCheck;
if (lastCheck && (now - lastCheck) > 60000) {
log('Health check failed - too long since last check');
handleReconnect();
return;
}
state.lastHealthCheck = now;
log('Health check passed');
}
function handleReconnect() {
if (state.reconnectTimeout) {
clearTimeout(state.reconnectTimeout);
state.reconnectTimeout = null;
}
config.reconnectAttempts++;
if (config.reconnectAttempts <= config.maxReconnectAttempts) {
const delay = Math.min(
config.reconnectDelay * Math.pow(1.5, config.reconnectAttempts - 1),
30000
);
log(`Scheduling reconnect in ${delay}ms (attempt ${config.reconnectAttempts})`);
state.reconnectTimeout = setTimeout(() => {
state.reconnectTimeout = null;
if (!state.isIntentionallyClosed) {
publicAPI.connect();
}
}, delay);
} else {
log('Max reconnect attempts reached');
if (typeof updateConnectionStatus === 'function') {
updateConnectionStatus('error');
}
state.reconnectTimeout = setTimeout(() => {
state.reconnectTimeout = null;
config.reconnectAttempts = 0;
publicAPI.connect();
}, 60000);
}
}
function notifyHandlers(type, data) {
if (state.messageHandlers[type]) {
Object.values(state.messageHandlers[type]).forEach(handler => {
try {
handler(data);
} catch (error) {
log(`Error in ${type} handler:`, error);
}
});
}
}
function cleanup() {
log('Cleaning up WebSocket resources');
clearTimeout(state.connectTimeout);
stopHealthCheck();
if (state.reconnectTimeout) {
clearTimeout(state.reconnectTimeout);
state.reconnectTimeout = null;
}
state.isConnecting = false;
if (ws) {
ws.onopen = null;
ws.onmessage = null;
ws.onerror = null;
ws.onclose = null;
if (ws.readyState === WebSocket.OPEN) {
ws.close(1000, 'Cleanup');
}
ws = null;
window.ws = null;
}
}
return publicAPI;
})();
window.WebSocketManager = WebSocketManager;
document.addEventListener('DOMContentLoaded', function() {
if (!window.webSocketManagerInitialized) {
window.WebSocketManager.initialize();
window.webSocketManagerInitialized = true;
}
});
//console.log('WebSocketManager initialized with methods:', Object.keys(WebSocketManager));
console.log('WebSocketManager initialized');