JS/UI: Fix scrolling lag / tooltips + Various fixes and cleanup. (#236)

* JS/UI: Fix scrolling lag + Various fixes and cleanup.

* Fix clear button

* JS: Fix when page is hidden, reconnect and proper pause/resume logic.

* JS: Fix tooltips bugs.

* JS: Various fixes.

* JS: Fix fetch system.

* JS: Cleanup
This commit is contained in:
Gerlof van Ek 2025-01-21 20:10:52 +01:00 committed by GitHub
parent 443bd6917f
commit f084c6f538
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 938 additions and 309 deletions

View file

@ -0,0 +1,190 @@
(function(window) {
'use strict';
function positionElement(targetEl, triggerEl, placement = 'bottom', offsetDistance = 8) {
targetEl.style.visibility = 'hidden';
targetEl.style.display = 'block';
const triggerRect = triggerEl.getBoundingClientRect();
const targetRect = targetEl.getBoundingClientRect();
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
let top, left;
top = triggerRect.bottom + offsetDistance;
left = triggerRect.left + (triggerRect.width - targetRect.width) / 2;
switch (placement) {
case 'bottom-start':
left = triggerRect.left;
break;
case 'bottom-end':
left = triggerRect.right - targetRect.width;
break;
}
const viewport = {
width: window.innerWidth,
height: window.innerHeight
};
if (left < 10) left = 10;
if (left + targetRect.width > viewport.width - 10) {
left = viewport.width - targetRect.width - 10;
}
targetEl.style.position = 'fixed';
targetEl.style.top = `${Math.round(top)}px`;
targetEl.style.left = `${Math.round(left)}px`;
targetEl.style.margin = '0';
targetEl.style.maxHeight = `${viewport.height - top - 10}px`;
targetEl.style.overflow = 'auto';
targetEl.style.visibility = 'visible';
}
class Dropdown {
constructor(targetEl, triggerEl, options = {}) {
this._targetEl = targetEl;
this._triggerEl = triggerEl;
this._options = {
placement: options.placement || 'bottom',
offset: options.offset || 5,
onShow: options.onShow || function() {},
onHide: options.onHide || function() {}
};
this._visible = false;
this._initialized = false;
this._handleScroll = this._handleScroll.bind(this);
this._handleResize = this._handleResize.bind(this);
this._handleOutsideClick = this._handleOutsideClick.bind(this);
this.init();
}
init() {
if (!this._initialized) {
this._targetEl.style.margin = '0';
this._targetEl.style.display = 'none';
this._targetEl.style.position = 'fixed';
this._targetEl.style.zIndex = '50';
this._setupEventListeners();
this._initialized = true;
}
}
_setupEventListeners() {
this._triggerEl.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
document.addEventListener('click', this._handleOutsideClick);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.hide();
});
window.addEventListener('scroll', this._handleScroll, true);
window.addEventListener('resize', this._handleResize);
}
_handleScroll() {
if (this._visible) {
requestAnimationFrame(() => {
positionElement(
this._targetEl,
this._triggerEl,
this._options.placement,
this._options.offset
);
});
}
}
_handleResize() {
if (this._visible) {
requestAnimationFrame(() => {
positionElement(
this._targetEl,
this._triggerEl,
this._options.placement,
this._options.offset
);
});
}
}
_handleOutsideClick(e) {
if (this._visible &&
!this._targetEl.contains(e.target) &&
!this._triggerEl.contains(e.target)) {
this.hide();
}
}
show() {
if (!this._visible) {
this._targetEl.style.display = 'block';
this._targetEl.style.visibility = 'hidden';
requestAnimationFrame(() => {
positionElement(
this._targetEl,
this._triggerEl,
this._options.placement,
this._options.offset
);
this._visible = true;
this._options.onShow();
});
}
}
hide() {
if (this._visible) {
this._targetEl.style.display = 'none';
this._visible = false;
this._options.onHide();
}
}
toggle() {
if (this._visible) {
this.hide();
} else {
this.show();
}
}
destroy() {
document.removeEventListener('click', this._handleOutsideClick);
window.removeEventListener('scroll', this._handleScroll, true);
window.removeEventListener('resize', this._handleResize);
this._initialized = false;
}
}
function initDropdowns() {
document.querySelectorAll('[data-dropdown-toggle]').forEach(triggerEl => {
const targetId = triggerEl.getAttribute('data-dropdown-toggle');
const targetEl = document.getElementById(targetId);
if (targetEl) {
const placement = triggerEl.getAttribute('data-dropdown-placement');
new Dropdown(targetEl, triggerEl, {
placement: placement || 'bottom-start'
});
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initDropdowns);
} else {
initDropdowns();
}
window.Dropdown = Dropdown;
window.initDropdowns = initDropdowns;
})(window);

File diff suppressed because one or more lines are too long

View file

@ -155,6 +155,9 @@ const WebSocketManager = {
maxQueueSize: 1000,
isIntentionallyClosed: false,
handlers: {},
isPageHidden: document.hidden,
priceUpdatePaused: false,
lastVisibilityChange: Date.now(),
connectionState: {
isConnecting: false,
@ -165,7 +168,6 @@ const WebSocketManager = {
},
initialize() {
console.log('Initializing WebSocket Manager');
this.setupPageVisibilityHandler();
this.connect();
this.startHealthCheck();
@ -179,12 +181,12 @@ const WebSocketManager = {
this.handlePageVisible();
}
};
document.addEventListener('visibilitychange', this.handlers.visibilityChange);
},
handlePageHidden() {
console.log('📱 Page hidden, suspending operations');
this.isPageHidden = true;
this.priceUpdatePaused = true;
this.stopHealthCheck();
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.isIntentionallyClosed = true;
@ -193,12 +195,17 @@ const WebSocketManager = {
},
handlePageVisible() {
console.log('📱 Page visible, resuming operations');
this.isPageHidden = false;
this.lastVisibilityChange = Date.now();
this.isIntentionallyClosed = false;
setTimeout(() => {
this.priceUpdatePaused = false;
if (!this.isConnected()) {
this.connect();
}
this.startHealthCheck();
}, 1000);
},
startHealthCheck() {
@ -217,7 +224,6 @@ const WebSocketManager = {
performHealthCheck() {
if (!this.isConnected()) {
console.warn('Health check: Connection lost, attempting reconnect');
this.handleReconnect();
return;
}
@ -225,13 +231,11 @@ const WebSocketManager = {
const now = Date.now();
const lastCheck = this.connectionState.lastHealthCheck;
if (lastCheck && (now - lastCheck) > 60000) {
console.warn('Health check: Connection stale, refreshing');
this.handleReconnect();
return;
}
this.connectionState.lastHealthCheck = now;
console.log('Health check passed');
},
connect() {
@ -248,7 +252,6 @@ const WebSocketManager = {
const wsPort = config.port || window.ws_port || '11700';
if (!wsPort) {
console.error('WebSocket port not configured');
this.connectionState.isConnecting = false;
return false;
}
@ -258,7 +261,6 @@ const WebSocketManager = {
this.connectionState.connectTimeout = setTimeout(() => {
if (this.connectionState.isConnecting) {
console.log('⏳ Connection attempt timed out');
this.cleanup();
this.handleReconnect();
}
@ -266,7 +268,6 @@ const WebSocketManager = {
return true;
} catch (error) {
console.error('Error creating WebSocket:', error);
this.connectionState.isConnecting = false;
this.handleReconnect();
return false;
@ -277,7 +278,6 @@ const WebSocketManager = {
if (!this.ws) return;
this.handlers.open = () => {
console.log('🟢 WebSocket connected successfully');
this.connectionState.isConnecting = false;
this.reconnectAttempts = 0;
clearTimeout(this.connectionState.connectTimeout);
@ -291,18 +291,15 @@ const WebSocketManager = {
const message = JSON.parse(event.data);
this.handleMessage(message);
} catch (error) {
console.error('Error processing WebSocket message:', error);
updateConnectionStatus('error');
}
};
this.handlers.error = (error) => {
console.error('WebSocket error:', error);
updateConnectionStatus('error');
};
this.handlers.close = (event) => {
console.log('🔴 WebSocket closed:', event.code, event.reason);
this.connectionState.isConnecting = false;
window.ws = null;
updateConnectionStatus('disconnected');
@ -320,7 +317,6 @@ const WebSocketManager = {
handleMessage(message) {
if (this.messageQueue.length >= this.maxQueueSize) {
console.warn('Message queue full, dropping oldest message');
this.messageQueue.shift();
}
@ -359,7 +355,6 @@ const WebSocketManager = {
this.messageQueue = [];
} catch (error) {
console.error('Error processing message queue:', error);
} finally {
this.processingQueue = false;
}
@ -372,8 +367,6 @@ const WebSocketManager = {
this.reconnectAttempts++;
if (this.reconnectAttempts <= this.maxReconnectAttempts) {
console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
const delay = Math.min(
this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1),
30000
@ -385,9 +378,7 @@ const WebSocketManager = {
}
}, delay);
} else {
console.error('Max reconnection attempts reached');
updateConnectionStatus('error');
setTimeout(() => {
this.reconnectAttempts = 0;
this.connect();
@ -396,8 +387,6 @@ const WebSocketManager = {
},
cleanup() {
console.log('Cleaning up WebSocket resources');
clearTimeout(this.debounceTimeout);
clearTimeout(this.reconnectTimeout);
clearTimeout(this.connectionState.connectTimeout);
@ -862,7 +851,7 @@ function continueInitialization() {
//console.log('Initialization completed');
}
function initializeFlowbiteTooltips() {
function initializeTooltips() {
if (typeof Tooltip === 'undefined') {
console.warn('Tooltip is not defined. Make sure the required library is loaded.');
return;
@ -1074,6 +1063,11 @@ function getEmptyPriceData() {
}
async function fetchLatestPrices() {
if (WebSocketManager.isPageHidden || WebSocketManager.priceUpdatePaused) {
const cachedData = CacheManager.get('prices_coingecko');
return cachedData?.value || getEmptyPriceData();
}
const PRICES_CACHE_KEY = 'prices_coingecko';
const minRequestInterval = 60000;
const currentTime = Date.now();
@ -1081,7 +1075,6 @@ async function fetchLatestPrices() {
if (!window.isManualRefresh) {
const lastRequestTime = window.lastPriceRequest || 0;
if (currentTime - lastRequestTime < minRequestInterval) {
console.log('Request too soon, using cache');
const cachedData = CacheManager.get(PRICES_CACHE_KEY);
if (cachedData) {
return cachedData.value;
@ -1089,10 +1082,10 @@ async function fetchLatestPrices() {
}
}
window.lastPriceRequest = currentTime;
if (!window.isManualRefresh) {
const cachedData = CacheManager.get(PRICES_CACHE_KEY);
if (cachedData && cachedData.remainingTime > 60000) {
console.log('Using cached price data');
latestPrices = cachedData.value;
Object.entries(cachedData.value).forEach(([coin, prices]) => {
if (prices.usd) {
@ -1102,11 +1095,12 @@ async function fetchLatestPrices() {
return cachedData.value;
}
}
try {
console.log('Initiating fresh price data fetch...');
const existingCache = CacheManager.get(PRICES_CACHE_KEY, true);
const fallbackData = existingCache ? existingCache.value : null;
const url = `${offersConfig.apiEndpoints.coinGecko}/simple/price?ids=bitcoin,bitcoin-cash,dash,dogecoin,decred,litecoin,particl,pivx,monero,zano,wownero,zcoin&vs_currencies=USD,BTC&api_key=${offersConfig.apiKeys.coinGecko}`;
const response = await fetch('/json/readurl', {
method: 'POST',
headers: {
@ -1121,19 +1115,21 @@ async function fetchLatestPrices() {
}
})
});
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
if (data.Error) {
if (fallbackData) {
console.log('Using fallback data due to API error');
return fallbackData;
}
throw new Error(data.Error);
}
if (data && Object.keys(data).length > 0) {
console.log('Processing fresh price data');
latestPrices = data;
CacheManager.set(PRICES_CACHE_KEY, data, CACHE_DURATION);
@ -1144,10 +1140,11 @@ async function fetchLatestPrices() {
});
return data;
}
if (fallbackData) {
console.log('Using fallback data due to empty response');
return fallbackData;
}
const fallbackPrices = {};
Object.keys(getEmptyPriceData()).forEach(coin => {
const fallbackValue = tableRateModule.getFallbackValue(coin);
@ -1155,14 +1152,13 @@ async function fetchLatestPrices() {
fallbackPrices[coin] = { usd: fallbackValue, btc: null };
}
});
if (Object.keys(fallbackPrices).length > 0) {
return fallbackPrices;
}
console.warn('No price data received');
return null;
} catch (error) {
console.error('Price Fetch Error:', error);
const fallbackPrices = {};
Object.keys(getEmptyPriceData()).forEach(coin => {
const fallbackValue = tableRateModule.getFallbackValue(coin);
@ -1556,7 +1552,7 @@ async function updateOffersTable() {
offersBody.appendChild(fragment);
requestAnimationFrame(() => {
initializeFlowbiteTooltips();
initializeTooltips();
updateRowTimes();
updatePaginationControls(totalPages);
@ -2014,14 +2010,12 @@ function createTooltips(offer, treatAsSentOffer, coinFrom, coinTo, fromAmount, t
Grey: Less than 5 minutes left or expired
</p>
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<div id="tooltip-wallet-${uniqueId}" role="tooltip" class="inline-block absolute invisible z-50 py-2 px-3 text-sm font-medium text-white bg-gray-400 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">
<div class="active-revoked-expired">
<span class="bold">${treatAsSentOffer ? 'My' : ''} ${coinTo} Wallet</span>
</div>
<div class="tooltip-arrow pl-1" data-popper-arrow></div>
</div>
<div id="tooltip-offer-${uniqueId}" role="tooltip" class="inline-block absolute z-50 py-2 px-3 text-sm font-medium text-white ${isRevoked ? 'bg-red-500' : (offer.is_own_offer ? 'bg-gray-300' : 'bg-green-700')} rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">
@ -2030,28 +2024,24 @@ function createTooltips(offer, treatAsSentOffer, coinFrom, coinTo, fromAmount, t
${isRevoked ? 'Offer Revoked' : (offer.is_own_offer ? 'Edit Offer' : `Buy ${coinFrom}`)}
</span>
</div>
<div class="tooltip-arrow pr-6" data-popper-arrow></div>
</div>
<div id="tooltip-wallet-maker-${uniqueId}" role="tooltip" class="inline-block absolute invisible z-50 py-2 px-3 text-sm font-medium text-white bg-gray-400 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">
<div class="active-revoked-expired">
<span class="bold">${treatAsSentOffer ? 'My' : ''} ${coinFrom} Wallet</span>
</div>
<div class="tooltip-arrow pl-1" data-popper-arrow></div>
</div>
<div id="tooltip-rate-${uniqueId}" role="tooltip" class="inline-block absolute invisible z-50 py-2 px-3 text-sm font-medium text-white bg-gray-400 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">
<div class="tooltip-content">
${combinedRateTooltip}
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<div id="percentage-tooltip-${uniqueId}" role="tooltip" class="inline-block absolute invisible z-50 py-2 px-3 text-sm font-medium text-white bg-gray-400 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">
<div class="tooltip-content">
${percentageTooltipContent}
</div>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
${createRecipientTooltip(uniqueId, identityInfo, identity, successRate, totalBids)}

View file

@ -35,7 +35,19 @@ const config = {
sixMonths: { days: 180, interval: 'daily' },
day: { days: 1, interval: 'hourly' }
},
currentResolution: 'year'
currentResolution: 'year',
requestTimeout: 60000, // 60 sec
retryDelays: [5000, 15000, 30000],
rateLimits: {
coingecko: {
requestsPerMinute: 50,
minInterval: 1200 // 1.2 sec
},
cryptocompare: {
requestsPerMinute: 30,
minInterval: 2000 // 2 sec
}
}
};
// UTILS
@ -82,8 +94,13 @@ const api = {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/json/readurl');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.timeout = 30000;
xhr.ontimeout = () => reject(new AppError('Request timed out'));
xhr.timeout = config.requestTimeout;
xhr.ontimeout = () => {
logger.warn(`Request timed out for ${url}`);
reject(new AppError('Request timed out'));
};
xhr.onload = () => {
logger.log(`Response for ${url}:`, xhr.responseText);
if (xhr.status === 200) {
@ -104,7 +121,12 @@ const api = {
reject(new AppError(`HTTP Error: ${xhr.status} ${xhr.statusText}`, 'HTTPError'));
}
};
xhr.onerror = () => reject(new AppError('Network error occurred', 'NetworkError'));
xhr.onerror = () => {
logger.error(`Network error occurred for ${url}`);
reject(new AppError('Network error occurred', 'NetworkError'));
};
xhr.send(JSON.stringify({
url: url,
headers: headers
@ -123,6 +145,12 @@ const api = {
try {
return await api.makePostRequest(url, headers);
} catch (error) {
logger.error(`CryptoCompare request failed for ${coin}:`, error);
const cachedData = cache.get(`coinData_${coin}`);
if (cachedData) {
logger.info(`Using cached data for ${coin}`);
return cachedData.value;
}
return { error: error.message };
}
});
@ -282,11 +310,11 @@ const api = {
const rateLimiter = {
lastRequestTime: {},
minRequestInterval: {
coingecko: 30000,
cryptocompare: 2000
coingecko: config.rateLimits.coingecko.minInterval,
cryptocompare: config.rateLimits.cryptocompare.minInterval
},
requestQueue: {},
retryDelays: [2000, 5000, 10000],
retryDelays: config.retryDelays,
canMakeRequest: function(apiName) {
const now = Date.now();
@ -328,6 +356,19 @@ const rateLimiter = {
await new Promise(resolve => setTimeout(resolve, delay));
return this.queueRequest(apiName, requestFn, retryCount + 1);
}
if ((error.message.includes('timeout') || error.name === 'NetworkError') &&
retryCount < this.retryDelays.length) {
const delay = this.retryDelays[retryCount];
logger.warn(`Request failed, retrying in ${delay/1000} seconds...`, {
apiName,
retryCount,
error: error.message
});
await new Promise(resolve => setTimeout(resolve, delay));
return this.queueRequest(apiName, requestFn, retryCount + 1);
}
throw error;
}
};
@ -336,10 +377,12 @@ const rateLimiter = {
return await this.requestQueue[apiName];
} catch (error) {
if (error.message.includes('429')) {
if (error.message.includes('429') ||
error.message.includes('timeout') ||
error.name === 'NetworkError') {
const cachedData = cache.get(`coinData_${apiName}`);
if (cachedData) {
console.log('Rate limit reached, using cached data');
console.log('Using cached data due to request failure');
return cachedData.value;
}
}
@ -439,8 +482,8 @@ displayCoinData: (coin, data) => {
}
updateUI(false);
} catch (error) {
logger.error('Failed to parse cache item:', error.message);
localStorage.removeItem(key);
logger.error(`Failed to display data for ${coin}:`, error.message);
updateUI(true); // Show error state in UI
}
},
@ -664,6 +707,8 @@ const chartModule = {
family: "'Inter', sans-serif"
},
color: 'rgba(156, 163, 175, 1)',
maxRotation: 0,
minRotation: 0,
callback: function(value) {
const date = new Date(value);
if (config.currentResolution === 'day') {
@ -857,68 +902,67 @@ const chartModule = {
updateChart: async (coinSymbol, forceRefresh = false) => {
try {
const currentChartData = chartModule.chart?.data.datasets[0].data || [];
if (currentChartData.length === 0) {
chartModule.showChartLoader();
}
chartModule.loadStartTime = Date.now();
const cacheKey = `chartData_${coinSymbol}_${config.currentResolution}`;
let cachedData = !forceRefresh ? cache.get(cacheKey) : null;
let data;
if (cachedData && Object.keys(cachedData.value).length > 0) {
data = cachedData.value;
//console.log(`Using cached data for ${coinSymbol} (${config.currentResolution})`);
console.log(`Using cached data for ${coinSymbol}`);
} else {
//console.log(`Fetching fresh data for ${coinSymbol} (${config.currentResolution})`);
try {
const allData = await api.fetchHistoricalDataXHR([coinSymbol]);
data = allData[coinSymbol];
if (!data || Object.keys(data).length === 0) {
throw new Error(`No data returned for ${coinSymbol}`);
}
//console.log(`Caching new data for ${cacheKey}`);
cache.set(cacheKey, data, config.cacheTTL);
cachedData = null;
} catch (error) {
if (error.message.includes('429') && currentChartData.length > 0) {
console.warn(`Rate limit hit for ${coinSymbol}, maintaining current chart`);
return;
}
const expiredCache = localStorage.getItem(cacheKey);
if (expiredCache) {
try {
const parsedCache = JSON.parse(expiredCache);
data = parsedCache.value;
console.log(`Using expired cache data for ${coinSymbol}`);
} catch (cacheError) {
throw error;
}
} else {
throw error;
}
}
}
const chartData = chartModule.prepareChartData(coinSymbol, data);
//console.log(`Prepared chart data for ${coinSymbol}:`, chartData.slice(0, 5));
if (chartData.length === 0) {
throw new Error(`No valid chart data for ${coinSymbol}`);
}
if (chartModule.chart) {
if (chartData.length > 0 && chartModule.chart) {
chartModule.chart.data.datasets[0].data = chartData;
chartModule.chart.data.datasets[0].label = `${coinSymbol} Price (USD)`;
if (coinSymbol === 'WOW') {
chartModule.chart.options.scales.x.time.unit = 'hour';
chartModule.chart.options.scales.x.ticks.maxTicksLimit = 24;
} else {
const resolution = config.resolutions[config.currentResolution];
chartModule.chart.options.scales.x.time.unit = resolution.interval === 'hourly' ? 'hour' : 'day';
if (config.currentResolution === 'year' || config.currentResolution === 'sixMonths') {
chartModule.chart.options.scales.x.time.unit = 'month';
chartModule.chart.options.scales.x.time.unit =
resolution.interval === 'hourly' ? 'hour' :
config.currentResolution === 'year' ? 'month' : 'day';
}
if (config.currentResolution === 'year') {
chartModule.chart.options.scales.x.ticks.maxTicksLimit = 12;
} else if (config.currentResolution === 'sixMonths') {
chartModule.chart.options.scales.x.ticks.maxTicksLimit = 6;
} else if (config.currentResolution === 'day') {
chartModule.chart.options.scales.x.ticks.maxTicksLimit = 24;
}
}
chartModule.chart.update('active');
} else {
//console.error('Chart object not initialized');
throw new Error('Chart object not initialized');
}
chartModule.currentCoin = coinSymbol;
const loadTime = Date.now() - chartModule.loadStartTime;
ui.updateLoadTimeAndCache(loadTime, cachedData);
}
} catch (error) {
//console.error(`Error updating chart for ${coinSymbol}:`, error);
ui.displayErrorMessage(`Failed to update chart for ${coinSymbol}: ${error.message}`);
console.error(`Error updating chart for ${coinSymbol}:`, error);
if (!(chartModule.chart?.data.datasets[0].data.length > 0)) {
chartModule.chart.data.datasets[0].data = [];
chartModule.chart.update('active');
}
} finally {
chartModule.hideChartLoader();
}
@ -994,8 +1038,8 @@ const app = {
disabled: 'Auto-refresh: disabled',
justRefreshed: 'Just refreshed',
},
cacheTTL: 5 * 60 * 1000, // 5 minutes
minimumRefreshInterval: 60 * 1000, // 1 minute
cacheTTL: 5 * 60 * 1000, // 5 min
minimumRefreshInterval: 60 * 1000, // 1 min
init: () => {
console.log('Initializing app...');
@ -1208,7 +1252,6 @@ setupEventListeners: () => {
return;
}
const lastGeckoRequest = rateLimiter.lastRequestTime['coingecko'] || 0;
const timeSinceLastRequest = Date.now() - lastGeckoRequest;
const waitTime = Math.max(0, rateLimiter.minRequestInterval.coingecko - timeSinceLastRequest);
@ -1217,7 +1260,6 @@ setupEventListeners: () => {
const seconds = Math.ceil(waitTime / 1000);
ui.displayErrorMessage(`Rate limit: Please wait ${seconds} seconds before refreshing`);
let remainingTime = seconds;
const countdownInterval = setInterval(() => {
remainingTime--;
@ -1283,7 +1325,6 @@ setupEventListeners: () => {
await chartModule.updateChart(chartModule.currentCoin, true);
} catch (chartError) {
console.error('Chart update failed:', chartError);
}
}
@ -1315,7 +1356,6 @@ setupEventListeners: () => {
} catch (error) {
console.error('Critical error during refresh:', error);
let countdown = 10;
ui.displayErrorMessage(`Refresh failed: ${error.message}. Please try again later. (${countdown}s)`);
@ -1338,7 +1378,7 @@ setupEventListeners: () => {
app.scheduleNextRefresh();
}
}
},
},
updateNextRefreshTime: () => {
console.log('Updating next refresh time display');

115
basicswap/static/js/tabs.js Normal file
View file

@ -0,0 +1,115 @@
(function(window) {
'use strict';
class Tabs {
constructor(tabsEl, items = [], options = {}) {
this._tabsEl = tabsEl;
this._items = items;
this._activeTab = options.defaultTabId ? this.getTab(options.defaultTabId) : null;
this._options = {
defaultTabId: options.defaultTabId || null,
activeClasses: options.activeClasses || 'text-blue-600 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-500 border-blue-600 dark:border-blue-500',
inactiveClasses: options.inactiveClasses || 'dark:border-transparent text-gray-500 hover:text-gray-600 dark:text-gray-400 border-gray-100 hover:border-gray-300 dark:border-gray-700 dark:hover:text-gray-300',
onShow: options.onShow || function() {}
};
this._initialized = false;
this.init();
}
init() {
if (this._items.length && !this._initialized) {
if (!this._activeTab) {
this.setActiveTab(this._items[0]);
}
this.show(this._activeTab.id, true);
this._items.forEach(tab => {
tab.triggerEl.addEventListener('click', () => {
this.show(tab.id);
});
});
this._initialized = true;
}
}
show(tabId, force = false) {
const tab = this.getTab(tabId);
if ((tab !== this._activeTab) || force) {
this._items.forEach(t => {
if (t !== tab) {
t.triggerEl.classList.remove(...this._options.activeClasses.split(' '));
t.triggerEl.classList.add(...this._options.inactiveClasses.split(' '));
t.targetEl.classList.add('hidden');
t.triggerEl.setAttribute('aria-selected', false);
}
});
tab.triggerEl.classList.add(...this._options.activeClasses.split(' '));
tab.triggerEl.classList.remove(...this._options.inactiveClasses.split(' '));
tab.triggerEl.setAttribute('aria-selected', true);
tab.targetEl.classList.remove('hidden');
this.setActiveTab(tab);
this._options.onShow(this, tab);
}
}
getTab(id) {
return this._items.find(t => t.id === id);
}
getActiveTab() {
return this._activeTab;
}
setActiveTab(tab) {
this._activeTab = tab;
}
}
function initTabs() {
document.querySelectorAll('[data-tabs-toggle]').forEach(tabsEl => {
const items = [];
let defaultTabId = null;
tabsEl.querySelectorAll('[role="tab"]').forEach(triggerEl => {
const isActive = triggerEl.getAttribute('aria-selected') === 'true';
const tab = {
id: triggerEl.getAttribute('data-tabs-target'),
triggerEl: triggerEl,
targetEl: document.querySelector(triggerEl.getAttribute('data-tabs-target'))
};
items.push(tab);
if (isActive) {
defaultTabId = tab.id;
}
});
new Tabs(tabsEl, items, {
defaultTabId: defaultTabId
});
});
}
const style = document.createElement('style');
style.textContent = `
[data-tabs-toggle] [role="tab"] {
cursor: pointer;
}
`;
document.head.appendChild(style);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTabs);
} else {
initTabs();
}
window.Tabs = Tabs;
window.initTabs = initTabs;
})(window);

View file

@ -0,0 +1,309 @@
(function(window) {
'use strict';
const tooltipContainer = document.createElement('div');
tooltipContainer.className = 'tooltip-container';
const style = document.createElement('style');
style.textContent = `
[role="tooltip"] {
position: absolute;
z-index: 9999;
transition: opacity 0.2s ease-in-out;
pointer-events: auto;
opacity: 0;
visibility: hidden;
}
.tooltip-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 0;
overflow: visible;
pointer-events: none;
z-index: 9999;
}
`;
function ensureContainerExists() {
if (!document.body.contains(tooltipContainer)) {
document.body.appendChild(tooltipContainer);
}
}
function rafThrottle(callback) {
let requestId = null;
let lastArgs = null;
const later = (context) => {
requestId = null;
callback.apply(context, lastArgs);
};
return function(...args) {
lastArgs = args;
if (requestId === null) {
requestId = requestAnimationFrame(() => later(this));
}
};
}
function positionElement(targetEl, triggerEl, placement = 'top', offsetDistance = 8) {
const triggerRect = triggerEl.getBoundingClientRect();
const targetRect = targetEl.getBoundingClientRect();
let top, left;
switch (placement) {
case 'top':
top = triggerRect.top - targetRect.height - offsetDistance;
left = triggerRect.left + (triggerRect.width - targetRect.width) / 2;
break;
case 'bottom':
top = triggerRect.bottom + offsetDistance;
left = triggerRect.left + (triggerRect.width - targetRect.width) / 2;
break;
case 'left':
top = triggerRect.top + (triggerRect.height - targetRect.height) / 2;
left = triggerRect.left - targetRect.width - offsetDistance;
break;
case 'right':
top = triggerRect.top + (triggerRect.height - targetRect.height) / 2;
left = triggerRect.right + offsetDistance;
break;
}
const viewport = {
width: window.innerWidth,
height: window.innerHeight
};
if (left < 0) left = 0;
if (top < 0) top = 0;
if (left + targetRect.width > viewport.width)
left = viewport.width - targetRect.width;
if (top + targetRect.height > viewport.height)
top = viewport.height - targetRect.height;
targetEl.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
}
const tooltips = new WeakMap();
class Tooltip {
constructor(targetEl, triggerEl, options = {}) {
ensureContainerExists();
this._targetEl = targetEl;
this._triggerEl = triggerEl;
this._options = {
placement: options.placement || 'top',
triggerType: options.triggerType || 'hover',
offset: options.offset || 8,
onShow: options.onShow || function() {},
onHide: options.onHide || function() {}
};
this._visible = false;
this._initialized = false;
this._hideTimeout = null;
this._showTimeout = null;
if (this._targetEl.parentNode !== tooltipContainer) {
tooltipContainer.appendChild(this._targetEl);
}
this._targetEl.style.visibility = 'hidden';
this._targetEl.style.opacity = '0';
this._showHandler = this.show.bind(this);
this._hideHandler = this._handleHide.bind(this);
this._updatePosition = rafThrottle(() => {
if (this._visible) {
positionElement(
this._targetEl,
this._triggerEl,
this._options.placement,
this._options.offset
);
}
});
this.init();
}
init() {
if (!this._initialized) {
this._setupEventListeners();
this._initialized = true;
positionElement(
this._targetEl,
this._triggerEl,
this._options.placement,
this._options.offset
);
}
}
_setupEventListeners() {
this._triggerEl.addEventListener('mouseenter', this._showHandler);
this._triggerEl.addEventListener('mouseleave', this._hideHandler);
this._triggerEl.addEventListener('focus', this._showHandler);
this._triggerEl.addEventListener('blur', this._hideHandler);
this._targetEl.addEventListener('mouseenter', () => {
clearTimeout(this._hideTimeout);
clearTimeout(this._showTimeout);
this._visible = true;
this._targetEl.style.visibility = 'visible';
this._targetEl.style.opacity = '1';
});
this._targetEl.addEventListener('mouseleave', this._hideHandler);
if (this._options.triggerType === 'click') {
this._triggerEl.addEventListener('click', this._showHandler);
}
window.addEventListener('scroll', this._updatePosition, { passive: true });
document.addEventListener('scroll', this._updatePosition, { passive: true, capture: true });
window.addEventListener('resize', this._updatePosition, { passive: true });
let rafId;
const smoothUpdate = () => {
if (this._visible) {
this._updatePosition();
rafId = requestAnimationFrame(smoothUpdate);
}
};
this._startSmoothUpdate = () => {
if (!rafId) rafId = requestAnimationFrame(smoothUpdate);
};
this._stopSmoothUpdate = () => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
};
}
_handleHide() {
clearTimeout(this._hideTimeout);
clearTimeout(this._showTimeout);
this._hideTimeout = setTimeout(() => {
if (this._visible) {
this.hide();
}
}, 100);
}
show() {
clearTimeout(this._hideTimeout);
clearTimeout(this._showTimeout);
this._showTimeout = setTimeout(() => {
if (!this._visible) {
positionElement(
this._targetEl,
this._triggerEl,
this._options.placement,
this._options.offset
);
this._targetEl.style.visibility = 'visible';
this._targetEl.style.opacity = '1';
this._visible = true;
this._startSmoothUpdate();
this._options.onShow();
}
}, 20);
}
hide() {
this._targetEl.style.opacity = '0';
this._targetEl.style.visibility = 'hidden';
this._visible = false;
this._stopSmoothUpdate();
this._options.onHide();
}
destroy() {
clearTimeout(this._hideTimeout);
clearTimeout(this._showTimeout);
this._stopSmoothUpdate();
this._triggerEl.removeEventListener('mouseenter', this._showHandler);
this._triggerEl.removeEventListener('mouseleave', this._hideHandler);
this._triggerEl.removeEventListener('focus', this._showHandler);
this._triggerEl.removeEventListener('blur', this._hideHandler);
this._targetEl.removeEventListener('mouseenter', this._showHandler);
this._targetEl.removeEventListener('mouseleave', this._hideHandler);
if (this._options.triggerType === 'click') {
this._triggerEl.removeEventListener('click', this._showHandler);
}
window.removeEventListener('scroll', this._updatePosition);
document.removeEventListener('scroll', this._updatePosition, true);
window.removeEventListener('resize', this._updatePosition);
this._targetEl.style.visibility = '';
this._targetEl.style.opacity = '';
this._targetEl.style.transform = '';
if (this._targetEl.parentNode === tooltipContainer) {
document.body.appendChild(this._targetEl);
}
this._initialized = false;
}
toggle() {
if (this._visible) {
this.hide();
} else {
this.show();
}
}
}
document.head.appendChild(style);
function initTooltips() {
ensureContainerExists();
document.querySelectorAll('[data-tooltip-target]').forEach(triggerEl => {
if (tooltips.has(triggerEl)) return;
const targetId = triggerEl.getAttribute('data-tooltip-target');
const targetEl = document.getElementById(targetId);
if (targetEl) {
const placement = triggerEl.getAttribute('data-tooltip-placement');
const triggerType = triggerEl.getAttribute('data-tooltip-trigger');
const tooltip = new Tooltip(targetEl, triggerEl, {
placement: placement || 'top',
triggerType: triggerType || 'hover',
offset: 8
});
tooltips.set(triggerEl, tooltip);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTooltips);
} else {
initTooltips();
}
window.Tooltip = Tooltip;
window.initTooltips = initTooltips;
})(window);

View file

@ -8,13 +8,18 @@
{% endif %}
<script src="/static/js/libs/chart.js"></script>
<script src="/static/js/libs/chartjs-adapter-date-fns.bundle.min.js"></script>
<link type="text/css" media="all" href="/static/css/libs/flowbite.min.css" rel="stylesheet" />
<link type="text/css" media="all" href="/static/css/libs/tailwind.min.css" rel="stylesheet">
<link type="text/css" media="all" href="/static/css/style.css" rel="stylesheet">
<script src="/static/js/main.js"></script>
<script src="/static/js/libs/flowbite.js"></script>
<script>
<script src="/static/js/main.js"></script>
<script src="/static/js/tabs.js"></script>
<script src="/static/js/dropdown.js"></script>
<script src="/static/js/tooltips.js"></script>
<script>
const isDarkMode =
localStorage.getItem('color-theme') === 'dark' ||
(!localStorage.getItem('color-theme') &&
@ -451,7 +456,6 @@ document.addEventListener('DOMContentLoaded', function() {
<div id="tooltip-your-offers" role="tooltip" class="inline-block absolute invisible z-10 py-2 px-3 text-sm font-medium text-white bg-blue-500 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">
<p><b>Total:</b> {{ summary.num_sent_offers }}</p>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</li>
@ -468,7 +472,7 @@ document.addEventListener('DOMContentLoaded', function() {
<div id="tooltip-bids-received" role="tooltip" class="inline-block absolute invisible z-10 py-2 px-3 text-sm font-medium text-white bg-blue-500 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">
<p><b>Total:</b> {{ summary.num_recv_bids }}</p>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<li>
<a data-tooltip-target="tooltip-bids-sent" class="flex mr-10 items-center text-sm text-gray-400 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-100" href="/sentbids">
@ -476,7 +480,7 @@ document.addEventListener('DOMContentLoaded', function() {
<span>Bids Sent</span><span class="inline-flex justify-center items-center text-xs font-semibold ml-3 mr-2 px-2.5 py-1 font-small text-white bg-blue-500 rounded-full">{{ summary.num_sent_active_bids }}</span></a>
<div id="tooltip-bids-sent" role="tooltip" class="inline-block absolute invisible z-10 py-2 px-3 text-sm font-medium text-white bg-blue-500 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">
<p><b>Total:</b> {{ summary.num_sent_bids }}</p>
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</li>

View file

@ -747,40 +747,24 @@ function resetForm() {
const bidAmountInput = document.getElementById('bid_amount');
const bidRateInput = document.getElementById('bid_rate');
const validMinsInput = document.querySelector('input[name="validmins"]');
const addrFromSelect = document.querySelector('select[name="addr_from"]');
const amtVar = document.getElementById('amt_var')?.value === 'True';
if (bidAmountSendInput) {
const defaultSendAmount = bidAmountSendInput.getAttribute('max');
bidAmountSendInput.value = defaultSendAmount;
bidAmountSendInput.value = amtVar ? '' : bidAmountSendInput.getAttribute('max');
}
if (bidAmountInput) {
const defaultReceiveAmount = bidAmountInput.getAttribute('max');
bidAmountInput.value = defaultReceiveAmount;
bidAmountInput.value = amtVar ? '' : bidAmountInput.getAttribute('max');
}
if (bidRateInput && !bidRateInput.disabled) {
const defaultRate = document.getElementById('offer_rate')?.value || '';
bidRateInput.value = defaultRate;
}
if (validMinsInput) {
validMinsInput.value = "60";
}
if (addrFromSelect) {
if (addrFromSelect.options.length > 1) {
addrFromSelect.selectedIndex = 1;
} else {
addrFromSelect.selectedIndex = 0;
}
const selectedOption = addrFromSelect.options[addrFromSelect.selectedIndex];
saveAddress(selectedOption.value, selectedOption.text);
}
if (!amtVar) {
updateBidParams('rate');
}
updateModalValues();
const errorMessages = document.querySelectorAll('.error-message');
errorMessages.forEach(msg => msg.remove());

View file

@ -26,10 +26,10 @@ function getWebSocketConfig() {
{% if sent_offers %}
<div class="lg:container mx-auto">
<section class="p-5 mt-5">
<div class="flex flex-wrap items-center">
<div class="w-full md:w-1/2 p-2">
<ul class="flex flex-wrap items-center gap-x-3 mb-2">
<section class="p-3 mt-2">
<div class="flex items-center">
<div class="w-full">
<ul class="flex items-center gap-x-2 mb-1">
<li><a class="flex font-medium text-xs text-coolGray-500 dark:text-gray-300 hover:text-coolGray-700" href="/">Home</a></li>
<li>{{ breadcrumb_line_svg | safe }}</li>
<li>
@ -46,26 +46,26 @@ function getWebSocketConfig() {
{% endif %}
{% if sent_offers %}
<section class="py-5">
<section class="py-3">
{% else %}
<section class="py-5 px-6 mt-5">
<section class="py-3 px-4 mt-3">
{% endif %}
<div class="lg:container mx-auto">
<div class="relative py-11 px-16 bg-coolGray-900 dark:bg-gray-500 rounded-md overflow-hidden">
<img class="absolute z-10 left-4 top-4 right-4 bottom-4" src="/static/images/elements/dots-red.svg" alt="dots-red">
<img class="absolute h-64 left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 object-cover" src="/static/images/elements/wave.svg" alt="wave">
<div class="relative z-20 flex flex-wrap items-center -m-3">
<div class="w-full md:w-1/2 p-3">
<h2 class="mb-6 text-4xl font-bold text-white tracking-tighter">{{ page_type }}</h2>
<div class="relative py-6 px-8 bg-coolGray-900 dark:bg-gray-500 rounded-md overflow-hidden">
<img class="absolute h-48 left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 object-cover" src="/static/images/elements/wave.svg" alt="wave">
<div class="relative z-20 flex flex-wrap items-center -m-2">
<div class="w-full md:w-1/2 p-2">
<h2 class="mb-3 text-3xl font-bold text-white tracking-tighter">{{ page_type }}</h2>
<p class="font-normal text-coolGray-200 dark:text-white">{{ page_type_description }}</p>
</div>
<div class="rounded-full{{ page_button }} w-full md:w-1/2 p-3 p-6 lg:container flex flex-wrap items-center justify-end items-center mx-auto">
<a id="refresh" href="/newoffer" class="rounded-full flex flex-wrap justify-center px-5 py-3 bg-blue-500 hover:bg-green-600 hover:border-green-600 font-medium text-sm text-white border border-blue-500 rounded-md focus:ring-0 focus:outline-none">{{ place_new_offer_svg | safe }}<span>Place new Offer</span></a>
<div class="rounded-full{{ page_button }} w-full md:w-1/2 p-2 lg:container flex flex-wrap items-center justify-end mx-auto">
<a id="refresh" href="/newoffer" class="rounded-full flex flex-wrap justify-center px-4 py-2 bg-blue-500 hover:bg-green-600 hover:border-green-600 font-medium text-sm text-white border border-blue-500 rounded-md focus:ring-0 focus:outline-none">{{ place_new_offer_svg | safe }}<span>Place new Offer</span></a>
</div>
</div>
</div>
</div>
</section>
</section>
{% include 'inc_messages.html' %}
@ -90,36 +90,28 @@ function getWebSocketConfig() {
<span id="load-time hidden" class="mr-2 text-sm text-gray-600 dark:text-gray-300"></span>
<span id="last-refreshed-time hidden" class="mr-2 text-sm text-gray-600 dark:text-gray-300"></span>
<span id="cache-status hidden" class="mr-4 text-sm text-gray-600 dark:text-gray-300"></span>
<span id="tor-status" class="mr-4 text-sm hidden {% if tor_established %}text-green-500{% else %}text-red-500{% endif %}"> Tor {% if tor_established %}ON{% else %}OFF{% endif %}
<span id="tor-status" class="mr-4 text-sm hidden {% if tor_established %}text-green-500{% else %}text-red-500{% endif %}">
Tor {% if tor_established %}ON{% else %}OFF{% endif %}
<a href="https://academy.particl.io/en/latest/basicswap-guides/basicswapguides_tor.html" target="_blank" rel="noopener noreferrer" class="underline">(?)</a>
</span>
<span id="next-refresh-time" class="mr-4 text-sm text-gray-600 dark:text-gray-300">
<span id="next-refresh-label"></span>
<span id="next-refresh-value"></span>
</span>
<div id="tooltip-volume" role="tooltip" class="inline-block absolute invisible z-10 py-2 px-3 text-sm font-medium text-white bg-blue-500 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">
Toggle Coin Volume
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<div id="tooltip-volume" role="tooltip" class="inline-block absolute invisible z-10 py-2 px-3 text-sm font-medium text-white bg-blue-500 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">Toggle Coin Volume</div>
<button id="toggle-volume" data-tooltip-target="tooltip-volume" class="text-white font-bold py-2 px-4 rounded mr-2 focus:outline-none focus:ring-0 transition-colors duration-200" title="Toggle Volume">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z" />
</svg>
</button>
<div id="tooltip-refresh" role="tooltip" class="inline-block absolute invisible z-10 py-2 px-3 text-sm font-medium text-white bg-blue-500 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">
Refresh Charts/Prices & Clear Cache
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<div id="tooltip-refresh" role="tooltip" class="inline-block absolute invisible z-10 py-2 px-3 text-sm font-medium text-white bg-blue-500 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">Refresh Charts/Prices & Clear Cache</div>
<button id="refresh-all" data-tooltip-target="tooltip-refresh" class="text-gray-600 dark:text-gray-400 font-bold py-2 px-4 rounded mr-2 focus:outline-none focus:ring-0 transition-colors duration-200" title="Refresh Data & Clear Cache">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
<div id="tooltip-auto" role="tooltip" class="inline-block absolute invisible z-10 py-2 px-3 text-sm font-medium text-white bg-blue-500 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">
Auto Refresh Enable/Disable
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
<div id="tooltip-auto" role="tooltip" class="inline-block absolute invisible z-10 py-2 px-3 text-sm font-medium text-white bg-blue-500 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip">Auto Refresh Enable/Disable</div>
<button id="toggle-auto-refresh" data-enabled="false" data-tooltip-target="tooltip-auto" class="text-white font-bold py-2 px-4 rounded mr-2 focus:outline-none focus:ring-0 transition-colors duration-200" title="Enable Auto-Refresh">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" />
@ -137,18 +129,25 @@ function getWebSocketConfig() {
</div>
</div>
</div>
<div id="error-overlay" class="error-overlay hidden absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div id="error-content" class="error-content bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-2xl mx-4 relative">
<button id="close-error" class="absolute top-3 right-3 bg-red-500 text-white rounded-full p-2 hover:bg-red-600 focus:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div id="error-overlay" class="notice-overlay hidden absolute inset-0 bg-opacity-30 backdrop-blur-sm flex items-center justify-center">
<div id="error-content" class="notice-content bg-white dark:bg-gray-800 rounded-xl p-6 w-full max-w-2xl mx-4 relative shadow-lg">
<div class="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-400 via-blue-500 to-blue-600"></div>
<button id="close-error" class="absolute top-3 right-3 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white rounded-full p-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors focus:outline-none">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<p class="text-red-600 font-semibold text-xl mb-4">Error</p>
<p id="error-message" class="text-gray-700 dark:text-gray-300 text-lg mb-6"></p>
<p class="text-sm text-gray-600 dark:text-gray-400">To review or update your Chart API Key(s), navigate to <a href="/settings" class="text-blue-500 hover:underline">Settings & Tools > Settings > General (TAB)</a>.
</p>
<div class="flex items-center gap-3 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">Notice</p>
</div>
<p id="error-message" class="text-gray-600 dark:text-gray-300 text-base mb-6"></p>
<div class="text-sm text-gray-500 dark:text-gray-400">
Need to update your (API) settings?
<a href="/settings" class="text-blue-500 hover:text-blue-600 dark:hover:text-blue-400 font-medium ml-1 hover:underline">Go to Settings -> General</a>
</div>
</div>
</div>
</div>