diff --git a/basicswap/js_server.py b/basicswap/js_server.py index b95c662..5e799fd 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -980,6 +980,67 @@ def js_readurl(self, url_split, post_string, is_json) -> bytes: raise ValueError("Requires URL.") +def js_active(self, url_split, post_string, is_json) -> bytes: + swap_client = self.server.swap_client + swap_client.checkSystemStatus() + filters = { + "sort_by": "created_at", + "sort_dir": "desc" + } + EXCLUDED_STATES = [ + 'Completed', + 'Expired', + 'Timed-out', + 'Abandoned', + 'Failed, refunded', + 'Failed, swiped', + 'Failed', + 'Error', + 'received' + ] + all_bids = [] + + try: + received_bids = swap_client.listBids(filters=filters) + sent_bids = swap_client.listBids(sent=True, filters=filters) + for bid in received_bids + sent_bids: + try: + bid_state = strBidState(bid[5]) + tx_state_a = strTxState(bid[7]) + tx_state_b = strTxState(bid[8]) + if bid_state in EXCLUDED_STATES: + continue + offer = swap_client.getOffer(bid[3]) + if not offer: + continue + swap_data = { + "bid_id": bid[2].hex(), + "offer_id": bid[3].hex(), + "created_at": bid[0], + "bid_state": bid_state, + "tx_state_a": tx_state_a if tx_state_a else 'None', + "tx_state_b": tx_state_b if tx_state_b else 'None', + "coin_from": swap_client.ci(bid[9]).coin_name(), + "coin_to": swap_client.ci(offer.coin_to).coin_name(), + "amount_from": swap_client.ci(bid[9]).format_amount(bid[4]), + "amount_to": swap_client.ci(offer.coin_to).format_amount( + (bid[4] * bid[10]) // swap_client.ci(bid[9]).COIN() + ), + "addr_from": bid[11], + "status": { + "main": bid_state, + "initial_tx": tx_state_a if tx_state_a else 'None', + "payment_tx": tx_state_b if tx_state_b else 'None' + } + } + all_bids.append(swap_data) + except Exception: + continue + except Exception: + return bytes(json.dumps([]), "UTF-8") + return bytes(json.dumps(all_bids), "UTF-8") + + pages = { "coins": js_coins, "wallets": js_wallets, @@ -1005,6 +1066,7 @@ pages = { "lock": js_lock, "help": js_help, "readurl": js_readurl, + "active": js_active, } diff --git a/basicswap/static/js/active.js b/basicswap/static/js/active.js new file mode 100644 index 0000000..d6d1767 --- /dev/null +++ b/basicswap/static/js/active.js @@ -0,0 +1,872 @@ +// Constants and State +const PAGE_SIZE = 50; +const COIN_NAME_TO_SYMBOL = { + 'Bitcoin': 'BTC', + 'Litecoin': 'LTC', + 'Monero': 'XMR', + 'Particl': 'PART', + 'Particl Blind': 'PART', + 'Particl Anon': 'PART', + 'PIVX': 'PIVX', + 'Firo': 'FIRO', + 'Dash': 'DASH', + 'Decred': 'DCR', + 'Wownero': 'WOW', + 'Bitcoin Cash': 'BCH', + 'Dogecoin': 'DOGE' +}; + +// Global state +const state = { + identities: new Map(), + currentPage: 1, + wsConnected: false, + swapsData: [], + isLoading: false, + isRefreshing: false, + refreshPromise: null +}; + +// DOM +const elements = { + swapsBody: document.getElementById('active-swaps-body'), + prevPageButton: document.getElementById('prevPage'), + nextPageButton: document.getElementById('nextPage'), + currentPageSpan: document.getElementById('currentPage'), + paginationControls: document.getElementById('pagination-controls'), + activeSwapsCount: document.getElementById('activeSwapsCount'), + refreshSwapsButton: document.getElementById('refreshSwaps'), + statusDot: document.getElementById('status-dot'), + statusText: document.getElementById('status-text') +}; + +// Identity Manager +const IdentityManager = { + cache: new Map(), + pendingRequests: new Map(), + retryDelay: 2000, + maxRetries: 3, + cacheTimeout: 5 * 60 * 1000, // 5 minutes + + async getIdentityData(address) { + if (!address) { + return { address: '' }; + } + + const cachedData = this.getCachedIdentity(address); + if (cachedData) { + return { ...cachedData, address }; + } + + if (this.pendingRequests.has(address)) { + const pendingData = await this.pendingRequests.get(address); + return { ...pendingData, address }; + } + + const request = this.fetchWithRetry(address); + this.pendingRequests.set(address, request); + + try { + const data = await request; + this.cache.set(address, { + data, + timestamp: Date.now() + }); + return { ...data, address }; + } catch (error) { + console.warn(`Error fetching identity for ${address}:`, error); + return { address }; + } finally { + this.pendingRequests.delete(address); + } + }, + + getCachedIdentity(address) { + const cached = this.cache.get(address); + if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) { + return cached.data; + } + if (cached) { + this.cache.delete(address); + } + return null; + }, + + async 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}`); + } + + const data = await response.json(); + return { + ...data, + address, + num_sent_bids_successful: safeParseInt(data.num_sent_bids_successful), + num_recv_bids_successful: safeParseInt(data.num_recv_bids_successful), + num_sent_bids_failed: safeParseInt(data.num_sent_bids_failed), + num_recv_bids_failed: safeParseInt(data.num_recv_bids_failed), + num_sent_bids_rejected: safeParseInt(data.num_sent_bids_rejected), + num_recv_bids_rejected: safeParseInt(data.num_recv_bids_rejected), + label: data.label || '', + note: data.note || '', + automation_override: safeParseInt(data.automation_override) + }; + } catch (error) { + if (attempt >= this.maxRetries) { + console.warn(`Failed to fetch identity for ${address} after ${attempt} attempts`); + return { + address, + num_sent_bids_successful: 0, + num_recv_bids_successful: 0, + num_sent_bids_failed: 0, + num_recv_bids_failed: 0, + num_sent_bids_rejected: 0, + num_recv_bids_rejected: 0, + label: '', + note: '', + automation_override: 0 + }; + } + + await new Promise(resolve => setTimeout(resolve, this.retryDelay * attempt)); + return this.fetchWithRetry(address, attempt + 1); + } + } +}; + +const safeParseInt = (value) => { + const parsed = parseInt(value); + return isNaN(parsed) ? 0 : parsed; +}; + +const getStatusClass = (status, tx_a, tx_b) => { + switch (status) { + case 'Completed': + return 'bg-green-300 text-black dark:bg-green-600 dark:text-white'; + case 'Expired': + case 'Timed-out': + return 'bg-gray-200 text-black dark:bg-gray-400 dark:text-white'; + case 'Error': + case 'Failed': + return 'bg-red-300 text-black dark:bg-red-600 dark:text-white'; + case 'Failed, swiped': + case 'Failed, refunded': + return 'bg-gray-200 text-black dark:bg-gray-400 dark:text-red-500'; + case 'InProgress': + case 'Script coin locked': + case 'Scriptless coin locked': + case 'Script coin lock released': + case 'SendingInitialTx': + case 'SendingPaymentTx': + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + case 'Received': + case 'Exchanged script lock tx sigs msg': + case 'Exchanged script lock spend tx msg': + case 'Script tx redeemed': + case 'Scriptless tx redeemed': + case 'Scriptless tx recovered': + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + case 'Accepted': + case 'Request accepted': + return 'bg-green-300 text-black dark:bg-green-600 dark:text-white'; + case 'Delaying': + case 'Auto accept delay': + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + case 'Abandoned': + case 'Rejected': + return 'bg-red-300 text-black dark:bg-red-600 dark:text-white'; + default: + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + } +}; + +const getTxStatusClass = (status) => { + if (!status || status === 'None') return 'text-gray-400'; + + if (status.includes('Complete') || status.includes('Confirmed')) { + return 'text-green-500'; + } + if (status.includes('Error') || status.includes('Failed')) { + return 'text-red-500'; + } + if (status.includes('Progress') || status.includes('Sending')) { + return 'text-yellow-500'; + } + return 'text-blue-500'; +}; + +// Util +const formatTimeAgo = (timestamp) => { + const now = Math.floor(Date.now() / 1000); + const diff = now - timestamp; + + if (diff < 60) return `${diff} seconds ago`; + if (diff < 3600) return `${Math.floor(diff / 60)} minutes ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`; + return `${Math.floor(diff / 86400)} days ago`; +}; + + +const formatTime = (timestamp) => { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +}; + +const formatAddress = (address, displayLength = 15) => { + if (!address) return ''; + if (address.length <= displayLength) return address; + return `${address.slice(0, displayLength)}...`; +}; + +const getStatusColor = (status) => { + const statusColors = { + 'Received': 'text-blue-500', + 'Accepted': 'text-green-500', + 'InProgress': 'text-yellow-500', + 'Complete': 'text-green-600', + 'Failed': 'text-red-500', + 'Expired': 'text-gray-500' + }; + return statusColors[status] || 'text-gray-500'; +}; + +const getTimeStrokeColor = (expireTime) => { + const now = Math.floor(Date.now() / 1000); + const timeLeft = expireTime - now; + + if (timeLeft <= 300) return '#9CA3AF'; // 5 minutes or less + if (timeLeft <= 1800) return '#3B82F6'; // 30 minutes or less + return '#10B981'; // More than 30 minutes +}; + +// WebSocket Manager +const WebSocketManager = { + ws: null, + processingQueue: false, + reconnectTimeout: null, + maxReconnectAttempts: 5, + reconnectAttempts: 0, + reconnectDelay: 5000, + + initialize() { + this.connect(); + this.startHealthCheck(); + }, + + connect() { + if (this.ws?.readyState === WebSocket.OPEN) return; + + try { + const wsPort = window.ws_port || '11700'; + this.ws = new WebSocket(`ws://${window.location.hostname}:${wsPort}`); + this.setupEventHandlers(); + } catch (error) { + console.error('WebSocket connection error:', error); + this.handleReconnect(); + } + }, + + setupEventHandlers() { + this.ws.onopen = () => { + state.wsConnected = true; + this.reconnectAttempts = 0; + updateConnectionStatus('connected'); + console.log('🟢 WebSocket connection established for Swaps in Progress'); + updateSwapsTable({ resetPage: true, refreshData: true }); + }; + + this.ws.onmessage = () => { + if (!this.processingQueue) { + this.processingQueue = true; + setTimeout(async () => { + try { + if (!state.isRefreshing) { + await updateSwapsTable({ resetPage: false, refreshData: true }); + } + } finally { + this.processingQueue = false; + } + }, 200); + } + }; + + this.ws.onclose = () => { + state.wsConnected = false; + updateConnectionStatus('disconnected'); + this.handleReconnect(); + }; + + this.ws.onerror = () => { + updateConnectionStatus('error'); + }; + }, + + startHealthCheck() { + setInterval(() => { + if (this.ws?.readyState !== WebSocket.OPEN) { + this.handleReconnect(); + } + }, 30000); + }, + + handleReconnect() { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + } + + this.reconnectAttempts++; + if (this.reconnectAttempts <= this.maxReconnectAttempts) { + const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1); + this.reconnectTimeout = setTimeout(() => this.connect(), delay); + } else { + updateConnectionStatus('error'); + setTimeout(() => { + this.reconnectAttempts = 0; + this.connect(); + }, 60000); + } + } +}; + +// UI +const updateConnectionStatus = (status) => { + const { statusDot, statusText } = elements; + if (!statusDot || !statusText) return; + + const statusConfig = { + connected: { + dotClass: 'w-2.5 h-2.5 rounded-full bg-green-500 mr-2', + textClass: 'text-sm text-green-500', + message: 'Connected' + }, + disconnected: { + dotClass: 'w-2.5 h-2.5 rounded-full bg-red-500 mr-2', + textClass: 'text-sm text-red-500', + message: 'Disconnected - Reconnecting...' + }, + error: { + dotClass: 'w-2.5 h-2.5 rounded-full bg-yellow-500 mr-2', + textClass: 'text-sm text-yellow-500', + message: 'Connection Error' + }, + default: { + dotClass: 'w-2.5 h-2.5 rounded-full bg-gray-500 mr-2', + textClass: 'text-sm text-gray-500', + message: 'Connecting...' + } + }; + + const config = statusConfig[status] || statusConfig.default; + statusDot.className = config.dotClass; + statusText.className = config.textClass; + statusText.textContent = config.message; +}; + +const updateLoadingState = (isLoading) => { + state.isLoading = isLoading; + if (elements.refreshSwapsButton) { + elements.refreshSwapsButton.disabled = isLoading; + elements.refreshSwapsButton.classList.toggle('opacity-75', isLoading); + elements.refreshSwapsButton.classList.toggle('cursor-wait', isLoading); + + const refreshIcon = elements.refreshSwapsButton.querySelector('svg'); + const refreshText = elements.refreshSwapsButton.querySelector('#refreshText'); + + if (refreshIcon) { + refreshIcon.style.transition = 'transform 0.3s ease'; + refreshIcon.classList.toggle('animate-spin', isLoading); + } + + if (refreshText) { + refreshText.textContent = isLoading ? 'Refreshing...' : 'Refresh'; + } + } +}; + +const processIdentityStats = (identity) => { + if (!identity) return null; + + const stats = { + sentSuccessful: safeParseInt(identity.num_sent_bids_successful), + recvSuccessful: safeParseInt(identity.num_recv_bids_successful), + sentFailed: safeParseInt(identity.num_sent_bids_failed), + recvFailed: safeParseInt(identity.num_recv_bids_failed), + sentRejected: safeParseInt(identity.num_sent_bids_rejected), + recvRejected: safeParseInt(identity.num_recv_bids_rejected) + }; + + stats.totalSuccessful = stats.sentSuccessful + stats.recvSuccessful; + stats.totalFailed = stats.sentFailed + stats.recvFailed; + stats.totalRejected = stats.sentRejected + stats.recvRejected; + stats.totalBids = stats.totalSuccessful + stats.totalFailed + stats.totalRejected; + + stats.successRate = stats.totalBids > 0 + ? ((stats.totalSuccessful / stats.totalBids) * 100).toFixed(1) + : '0.0'; + + return stats; +}; + +const createIdentityTooltip = (identity) => { + if (!identity) return ''; + + const stats = processIdentityStats(identity); + if (!stats) return ''; + + const getSuccessRateColor = (rate) => { + const numRate = parseFloat(rate); + if (numRate >= 80) return 'text-green-600'; + if (numRate >= 60) return 'text-yellow-600'; + return 'text-red-600'; + }; + + return ` + <div class="identity-info space-y-2"> + ${identity.label ? ` + <div class="border-b border-gray-400 pb-2"> + <div class="text-white text-xs tracking-wide font-semibold">Label:</div> + <div class="text-white">${identity.label}</div> + </div> + ` : ''} + + <div class="space-y-1"> + <div class="text-white text-xs tracking-wide font-semibold">Address:</div> + <div class="monospace text-xs break-all bg-gray-500 p-2 rounded-md text-white"> + ${identity.address || ''} + </div> + </div> + + ${identity.note ? ` + <div class="space-y-1"> + <div class="text-white text-xs tracking-wide font-semibold">Note:</div> + <div class="text-white text-sm italic">${identity.note}</div> + </div> + ` : ''} + + <div class="pt-2 mt-2"> + <div class="text-white text-xs tracking-wide font-semibold mb-2">Swap History:</div> + <div class="grid grid-cols-2 gap-2"> + <div class="text-center p-2 bg-gray-500 rounded-md"> + <div class="text-lg font-bold ${getSuccessRateColor(stats.successRate)}"> + ${stats.successRate}% + </div> + <div class="text-xs text-white">Success Rate</div> + </div> + <div class="text-center p-2 bg-gray-500 rounded-md"> + <div class="text-lg font-bold text-blue-500">${stats.totalBids}</div> + <div class="text-xs text-white">Total Trades</div> + </div> + </div> + <div class="grid grid-cols-3 gap-2 mt-2 text-center text-xs"> + <div> + <div class="text-green-600 font-semibold"> + ${stats.totalSuccessful} + </div> + <div class="text-white">Successful</div> + </div> + <div> + <div class="text-yellow-600 font-semibold"> + ${stats.totalRejected} + </div> + <div class="text-white">Rejected</div> + </div> + <div> + <div class="text-red-600 font-semibold"> + ${stats.totalFailed} + </div> + <div class="text-white">Failed</div> + </div> + </div> + </div> + </div> + `; +}; + +const createSwapTableRow = async (swap) => { + if (!swap || !swap.bid_id) { + console.warn('Invalid swap data:', swap); + return ''; + } + + const identity = await IdentityManager.getIdentityData(swap.addr_from); + const uniqueId = `${swap.bid_id}_${swap.created_at}`; + const fromSymbol = COIN_NAME_TO_SYMBOL[swap.coin_from] || swap.coin_from; + const toSymbol = COIN_NAME_TO_SYMBOL[swap.coin_to] || swap.coin_to; + const timeColor = getTimeStrokeColor(swap.expire_at); + const fromAmount = parseFloat(swap.amount_from) || 0; + const toAmount = parseFloat(swap.amount_to) || 0; + + return ` + <tr class="relative opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600" data-bid-id="${swap.bid_id}"> + <td class="relative w-0 p-0 m-0"> + <div class="absolute top-0 bottom-0 left-0 w-1"></div> + </td> + + <!-- Time Column --> + <td class="py-3 pl-1 pr-2 text-xs whitespace-nowrap"> + <div class="flex items-center"> + <div class="relative" data-tooltip-target="tooltip-time-${uniqueId}"> + <svg class="w-5 h-5 rounded-full mr-4 cursor-pointer" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <g stroke-linecap="round" stroke-width="2" fill="none" stroke="${timeColor}" stroke-linejoin="round"> + <circle cx="12" cy="12" r="11"></circle> + <polyline points="12,6 12,12 18,12"></polyline> + </g> + </svg> + </div> + <div class="flex flex-col hidden xl:block"> + <div class="text-xs whitespace-nowrap"> + <span class="bold">Posted:</span> ${formatTimeAgo(swap.created_at)} + </div> + </div> + </div> + </td> + + <!-- Details Column --> + <td class="py-8 px-4 text-xs text-left hidden xl:block"> + <div class="flex flex-col gap-2 relative"> + <div class="flex items-center"> + <a href="/identity/${swap.addr_from}" data-tooltip-target="tooltip-identity-${uniqueId}" class="flex items-center"> + <svg class="w-4 h-4 mr-2 text-gray-400 dark:text-white" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"></path> + </svg> + <span class="monospace ${identity?.label ? 'dark:text-white' : 'dark:text-white'}"> + ${identity?.label || formatAddress(swap.addr_from)} + </span> + </a> + </div> + <div class="monospace text-xs text-gray-500 dark:text-gray-300"> + <span class="font-semibold">Bid ID:</span> + <a href="/bid/${swap.bid_id}" data-tooltip-target="tooltip-bid-${uniqueId}" class="hover:underline"> + ${formatAddress(swap.bid_id)} + </a> + </div> + <div class="monospace text-xs text-gray-500 dark:text-gray-300"> + <span class="font-semibold">Offer ID:</span> + <a href="/offer/${swap.offer_id}" data-tooltip-target="tooltip-offer-${uniqueId}" class="hover:underline"> + ${formatAddress(swap.offer_id)} + </a> + </div> + </div> + </td> + <!-- You Send Column --> + <td class="py-0"> + <div class="py-3 px-4 text-left"> + <div class="items-center monospace"> + <div class="pr-2"> + <div class="text-sm font-semibold">${fromAmount.toFixed(8)}</div> + <div class="text-sm text-gray-500 dark:text-gray-400">${fromSymbol}</div> + </div> + </div> + </div> + </td> + + <!-- Swap Column --> + <td class="py-0"> + <div class="py-3 px-4 text-center"> + <div class="flex items-center justify-center"> + <span class="inline-flex mr-3 align-middle items-center justify-center w-18 h-20 rounded"> + <img class="h-12" + src="/static/images/coins/${swap.coin_from.replace(' ', '-')}.png" + alt="${swap.coin_from}" + onerror="this.src='/static/images/coins/default.png'"> + </span> + <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> + <path fill-rule="evenodd" d="M12.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-2.293-2.293a1 1 0 010-1.414z"></path> + </svg> + <span class="inline-flex ml-3 align-middle items-center justify-center w-18 h-20 rounded"> + <img class="h-12" + src="/static/images/coins/${swap.coin_to.replace(' ', '-')}.png" + alt="${swap.coin_to}" + onerror="this.src='/static/images/coins/default.png'"> + </span> + </div> + </div> + </td> + + <!-- You Receive Column --> + <td class="py-0"> + <div class="py-3 px-4 text-right"> + <div class="items-center monospace"> + <div class="text-sm font-semibold">${toAmount.toFixed(8)}</div> + <div class="text-sm text-gray-500 dark:text-gray-400">${toSymbol}</div> + </div> + </div> + </td> + + <!-- Status Column --> + <td class="py-3 px-4 text-center"> + <div data-tooltip-target="tooltip-status-${uniqueId}" class="flex justify-center"> + <span class="px-2.5 py-1 text-xs font-medium rounded-full ${getStatusClass(swap.bid_state, swap.tx_state_a, swap.tx_state_b)}"> + ${swap.bid_state} + </span> + </div> + </td> + + <!-- Actions Column --> + <td class="py-3 px-4 text-center"> + <a href="/bid/${swap.bid_id}" + class="inline-block w-20 py-1 px-2 font-medium text-center text-sm rounded-md bg-blue-500 text-white border border-blue-500 hover:bg-blue-600 transition duration-200"> + Details + </a> + </td> + + <!-- Tooltips --> + <div id="tooltip-time-${uniqueId}" role="tooltip" class="inline-block absolute 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 dark:bg-gray-600"> + <div class="active-revoked-expired"> + <span class="bold"> + <div class="text-xs"><span class="bold">Posted:</span> ${formatTimeAgo(swap.created_at)}</div> + <div class="text-xs"><span class="bold">Expires in:</span> ${formatTime(swap.expire_at)}</div> + </span> + </div> + <div class="mt-5 text-xs"> + <p class="font-bold mb-3">Time Indicator Colors:</p> + <p class="flex items-center"> + <svg class="w-5 h-5 mr-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <g stroke-linecap="round" stroke-width="2" fill="none" stroke="#10B981" stroke-linejoin="round"> + <circle cx="12" cy="12" r="11"></circle> + <polyline points="12,6 12,12 18,12" stroke="#10B981"></polyline> + </g> + </svg> + Green: More than 30 minutes left + </p> + <p class="flex items-center mt-3"> + <svg class="w-5 h-5 mr-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <g stroke-linecap="round" stroke-width="2" fill="none" stroke="#3B82F6" stroke-linejoin="round"> + <circle cx="12" cy="12" r="11"></circle> + <polyline points="12,6 12,12 18,12" stroke="#3B82F6"></polyline> + </g> + </svg> + Blue: Between 5 and 30 minutes left + </p> + <p class="flex items-center mt-3 mb-3"> + <svg class="w-5 h-5 mr-3" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <g stroke-linecap="round" stroke-width="2" fill="none" stroke="#9CA3AF" stroke-linejoin="round"> + <circle cx="12" cy="12" r="11"></circle> + <polyline points="12,6 12,12 18,12" stroke="#9CA3AF"></polyline> + </g> + </svg> + Grey: Less than 5 minutes left or expired + </p> + </div> + <div class="tooltip-arrow" data-popper-arrow></div> + </div> + + <div id="tooltip-identity-${uniqueId}" role="tooltip" class="inline-block absolute 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 dark:bg-gray-600"> + ${createIdentityTooltip(identity)} + <div class="tooltip-arrow" 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 bg-gray-400 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip dark:bg-gray-600"> + <div class="space-y-1"> + <div class="text-white text-xs tracking-wide font-semibold">Offer ID:</div> + <div class="monospace text-xs break-all"> + ${swap.offer_id} + </div> + </div> + <div class="tooltip-arrow" data-popper-arrow></div> + </div> + + <div id="tooltip-bid-${uniqueId}" role="tooltip" class="inline-block absolute 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 dark:bg-gray-600"> + <div class="space-y-1"> + <div class="text-white text-xs tracking-wide font-semibold">Bid ID:</div> + <div class="monospace text-xs break-all"> + ${swap.bid_id} + </div> + </div> + <div class="tooltip-arrow" data-popper-arrow></div> + </div> + + <div id="tooltip-status-${uniqueId}" role="tooltip" class="inline-block absolute 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 dark:bg-gray-600"> + <div class="text-white"> + <p class="font-bold mb-2">Transaction Status</p> + <div class="grid grid-cols-2 gap-2"> + <div class="bg-gray-500 p-2 rounded"> + <p class="text-xs font-bold">ITX:</p> + <p>${swap.tx_state_a || 'N/A'}</p> + </div> + <div class="bg-gray-500 p-2 rounded"> + <p class="text-xs font-bold">PTX:</p> + <p>${swap.tx_state_b || 'N/A'}</p> + </div> + </div> + </div> + <div class="tooltip-arrow" data-popper-arrow></div> + </div> + </tr> + `; +}; + +async function updateSwapsTable(options = {}) { + const { resetPage = false, refreshData = true } = options; + + if (state.refreshPromise) { + await state.refreshPromise; + return; + } + + try { + updateLoadingState(true); + + if (refreshData) { + state.refreshPromise = (async () => { + try { + const response = await fetch('/json/active', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sort_by: "created_at", + sort_dir: "desc" + }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + state.swapsData = Array.isArray(data) ? data : []; + } catch (error) { + console.error('Error fetching swap data:', error); + state.swapsData = []; + } finally { + state.refreshPromise = null; + } + })(); + + await state.refreshPromise; + } + + if (elements.activeSwapsCount) { + elements.activeSwapsCount.textContent = state.swapsData.length; + } + + const totalPages = Math.ceil(state.swapsData.length / PAGE_SIZE); + + if (resetPage && state.swapsData.length > 0) { + state.currentPage = 1; + } + + state.currentPage = Math.min(Math.max(1, state.currentPage), Math.max(1, totalPages)); + + const startIndex = (state.currentPage - 1) * PAGE_SIZE; + const endIndex = startIndex + PAGE_SIZE; + const currentPageSwaps = state.swapsData.slice(startIndex, endIndex); + + if (elements.swapsBody) { + if (currentPageSwaps.length > 0) { + const rowPromises = currentPageSwaps.map(swap => createSwapTableRow(swap)); + const rows = await Promise.all(rowPromises); + elements.swapsBody.innerHTML = rows.join(''); + + // Initialize tooltips + if (window.TooltipManager) { + window.TooltipManager.cleanup(); + const tooltipTriggers = document.querySelectorAll('[data-tooltip-target]'); + tooltipTriggers.forEach(trigger => { + const targetId = trigger.getAttribute('data-tooltip-target'); + const tooltipContent = document.getElementById(targetId); + if (tooltipContent) { + window.TooltipManager.create(trigger, tooltipContent.innerHTML, { + placement: trigger.getAttribute('data-tooltip-placement') || 'top' + }); + } + }); + } + } else { + elements.swapsBody.innerHTML = ` + <tr> + <td colspan="8" class="text-center py-4 text-gray-500 dark:text-white"> + No active swaps found + </td> + </tr>`; + } + } + + if (elements.paginationControls) { + elements.paginationControls.style.display = totalPages > 1 ? 'flex' : 'none'; + } + + if (elements.currentPageSpan) { + elements.currentPageSpan.textContent = state.currentPage; + } + + if (elements.prevPageButton) { + elements.prevPageButton.style.display = state.currentPage > 1 ? 'inline-flex' : 'none'; + } + + if (elements.nextPageButton) { + elements.nextPageButton.style.display = state.currentPage < totalPages ? 'inline-flex' : 'none'; + } + + } catch (error) { + console.error('Error updating swaps table:', error); + if (elements.swapsBody) { + elements.swapsBody.innerHTML = ` + <tr> + <td colspan="8" class="text-center py-4 text-red-500"> + Error loading active swaps. Please try again later. + </td> + </tr>`; + } + } finally { + updateLoadingState(false); + } +} + +// Event +const setupEventListeners = () => { + if (elements.refreshSwapsButton) { + elements.refreshSwapsButton.addEventListener('click', async (e) => { + e.preventDefault(); + if (state.isRefreshing) return; + + updateLoadingState(true); + try { + await updateSwapsTable({ resetPage: true, refreshData: true }); + } finally { + updateLoadingState(false); + } + }); + } + + if (elements.prevPageButton) { + elements.prevPageButton.addEventListener('click', async (e) => { + e.preventDefault(); + if (state.isLoading) return; + if (state.currentPage > 1) { + state.currentPage--; + await updateSwapsTable({ resetPage: false, refreshData: false }); + } + }); + } + + if (elements.nextPageButton) { + elements.nextPageButton.addEventListener('click', async (e) => { + e.preventDefault(); + if (state.isLoading) return; + const totalPages = Math.ceil(state.swapsData.length / PAGE_SIZE); + if (state.currentPage < totalPages) { + state.currentPage++; + await updateSwapsTable({ resetPage: false, refreshData: false }); + } + }); + } +}; + +// Init +document.addEventListener('DOMContentLoaded', () => { + WebSocketManager.initialize(); + setupEventListeners(); +}); diff --git a/basicswap/static/js/bids_available.js b/basicswap/static/js/bids_available.js index b3b7e48..a183fd2 100644 --- a/basicswap/static/js/bids_available.js +++ b/basicswap/static/js/bids_available.js @@ -374,7 +374,7 @@ const WebSocketManager = { state.wsConnected = true; this.reconnectAttempts = 0; updateConnectionStatus('connected'); - console.log('🟢 WebSocket connection established'); + console.log('🟢 WebSocket connection established for Bid Requests'); updateBidsTable({ resetPage: true, refreshData: true }); }; diff --git a/basicswap/static/js/bids_export.js b/basicswap/static/js/bids_export.js new file mode 100644 index 0000000..823a1e4 --- /dev/null +++ b/basicswap/static/js/bids_export.js @@ -0,0 +1,141 @@ +const BidExporter = { + toCSV(bids, type) { + if (!bids || !bids.length) { + return 'No data to export'; + } + + const isSent = type === 'sent'; + + const headers = [ + 'Date/Time', + 'Bid ID', + 'Offer ID', + 'From Address', + isSent ? 'You Send Amount' : 'You Receive Amount', + isSent ? 'You Send Coin' : 'You Receive Coin', + isSent ? 'You Receive Amount' : 'You Send Amount', + isSent ? 'You Receive Coin' : 'You Send Coin', + 'Status', + 'Created At', + 'Expires At' + ]; + + let csvContent = headers.join(',') + '\n'; + + bids.forEach(bid => { + const row = [ + `"${formatTime(bid.created_at)}"`, + `"${bid.bid_id}"`, + `"${bid.offer_id}"`, + `"${bid.addr_from}"`, + isSent ? bid.amount_from : bid.amount_to, + `"${isSent ? bid.coin_from : bid.coin_to}"`, + isSent ? bid.amount_to : bid.amount_from, + `"${isSent ? bid.coin_to : bid.coin_from}"`, + `"${bid.bid_state}"`, + bid.created_at, + bid.expire_at + ]; + + csvContent += row.join(',') + '\n'; + }); + + return csvContent; + }, + + download(content, filename) { + try { + const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' }); + + if (window.navigator && window.navigator.msSaveOrOpenBlob) { + window.navigator.msSaveOrOpenBlob(blob, filename); + return; + } + + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = url; + link.download = filename; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + setTimeout(() => { + URL.revokeObjectURL(url); + }, 100); + } catch (error) { + console.error('Error downloading CSV:', error); + + const csvData = 'data:text/csv;charset=utf-8,' + encodeURIComponent(content); + const link = document.createElement('a'); + link.setAttribute('href', csvData); + link.setAttribute('download', filename); + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + }, + + exportCurrentView() { + const type = state.currentTab; + const data = state.data[type]; + + if (!data || !data.length) { + alert('No data to export'); + return; + } + + const csvContent = this.toCSV(data, type); + + const now = new Date(); + const dateStr = now.toISOString().split('T')[0]; + const filename = `bsx_${type}_bids_${dateStr}.csv`; + + this.download(csvContent, filename); + } +}; + +document.addEventListener('DOMContentLoaded', function() { + setTimeout(function() { + if (typeof state !== 'undefined' && typeof EventManager !== 'undefined') { + const exportSentButton = document.getElementById('exportSentBids'); + if (exportSentButton) { + EventManager.add(exportSentButton, 'click', (e) => { + e.preventDefault(); + state.currentTab = 'sent'; + BidExporter.exportCurrentView(); + }); + } + + const exportReceivedButton = document.getElementById('exportReceivedBids'); + if (exportReceivedButton) { + EventManager.add(exportReceivedButton, 'click', (e) => { + e.preventDefault(); + state.currentTab = 'received'; + BidExporter.exportCurrentView(); + }); + } + } + }, 500); +}); + +const originalCleanup = window.cleanup || function(){}; +window.cleanup = function() { + originalCleanup(); + + const exportSentButton = document.getElementById('exportSentBids'); + const exportReceivedButton = document.getElementById('exportReceivedBids'); + + if (exportSentButton && typeof EventManager !== 'undefined') { + EventManager.remove(exportSentButton, 'click'); + } + + if (exportReceivedButton && typeof EventManager !== 'undefined') { + EventManager.remove(exportReceivedButton, 'click'); + } +}; diff --git a/basicswap/static/js/bids_sentreceived.js b/basicswap/static/js/bids_sentreceived.js index 61e1522..c3321a9 100644 --- a/basicswap/static/js/bids_sentreceived.js +++ b/basicswap/static/js/bids_sentreceived.js @@ -93,6 +93,140 @@ const elements = { refreshReceivedBids: document.getElementById('refreshReceivedBids') }; +const EventManager = { + listeners: new Map(), + + add(element, type, handler, options = false) { + if (!element) return null; + + if (!this.listeners.has(element)) { + this.listeners.set(element, new Map()); + } + + const elementListeners = this.listeners.get(element); + if (!elementListeners.has(type)) { + elementListeners.set(type, new Set()); + } + + const handlerInfo = { handler, options }; + elementListeners.get(type).add(handlerInfo); + element.addEventListener(type, handler, options); + + return handlerInfo; + }, + + remove(element, type, handler, options = false) { + if (!element) return; + + const elementListeners = this.listeners.get(element); + if (!elementListeners) return; + + const typeListeners = elementListeners.get(type); + if (!typeListeners) return; + + typeListeners.forEach(info => { + if (info.handler === handler) { + element.removeEventListener(type, handler, options); + typeListeners.delete(info); + } + }); + + if (typeListeners.size === 0) { + elementListeners.delete(type); + } + if (elementListeners.size === 0) { + this.listeners.delete(element); + } + }, + + removeAll(element) { + if (!element) return; + + const elementListeners = this.listeners.get(element); + if (!elementListeners) return; + + elementListeners.forEach((typeListeners, type) => { + typeListeners.forEach(info => { + try { + element.removeEventListener(type, info.handler, info.options); + } catch (e) { + console.warn('Error removing event listener:', e); + } + }); + }); + + this.listeners.delete(element); + }, + + clearAll() { + this.listeners.forEach((elementListeners, element) => { + this.removeAll(element); + }); + this.listeners.clear(); + } +}; + +function cleanup() { + console.log('Starting cleanup process'); + EventManager.clearAll(); + + const exportSentButton = document.getElementById('exportSentBids'); + const exportReceivedButton = document.getElementById('exportReceivedBids'); + + if (exportSentButton) { + exportSentButton.remove(); + } + + if (exportReceivedButton) { + exportReceivedButton.remove(); + } + + if (window.TooltipManager) { + const originalCleanup = window.TooltipManager.cleanup; + window.TooltipManager.cleanup = function() { + originalCleanup.call(window.TooltipManager); + + setTimeout(() => { + forceTooltipDOMCleanup(); + + const detachedTooltips = document.querySelectorAll('[id^="tooltip-"]'); + detachedTooltips.forEach(tooltip => { + const tooltipId = tooltip.id; + const trigger = document.querySelector(`[data-tooltip-target="${tooltipId}"]`); + if (!trigger || !document.body.contains(trigger)) { + tooltip.remove(); + } + }); + }, 10); + }; +} + + WebSocketManager.cleanup(); + if (searchTimeout) { + clearTimeout(searchTimeout); + searchTimeout = null; + } + state.data = { + sent: [], + received: [] + }; + IdentityManager.clearCache(); + Object.keys(elements).forEach(key => { + elements[key] = null; + }); + + console.log('Cleanup completed'); +} + +document.addEventListener('beforeunload', cleanup); +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + WebSocketManager.pause(); + } else { + WebSocketManager.resume(); + } +}); + // WebSocket Management const WebSocketManager = { ws: null, @@ -101,7 +235,10 @@ const WebSocketManager = { maxReconnectAttempts: 5, reconnectAttempts: 0, reconnectDelay: 5000, - + healthCheckInterval: null, + isPaused: false, + lastMessageTime: Date.now(), + initialize() { this.connect(); this.startHealthCheck(); @@ -112,7 +249,11 @@ const WebSocketManager = { }, connect() { - if (this.isConnected()) return; + if (this.isConnected() || this.isPaused) return; + + if (this.ws) { + this.cleanupConnection(); + } try { const wsPort = window.ws_port || '11700'; @@ -125,15 +266,21 @@ const WebSocketManager = { }, setupEventHandlers() { + if (!this.ws) return; + this.ws.onopen = () => { state.wsConnected = true; this.reconnectAttempts = 0; + this.lastMessageTime = Date.now(); updateConnectionStatus('connected'); - console.log('🟢 WebSocket connection established'); + console.log('🟢 WebSocket connection established for Sent Bids / Received Bids'); updateBidsTable(); }; this.ws.onmessage = () => { + this.lastMessageTime = Date.now(); + if (this.isPaused) return; + if (!this.processingQueue) { this.processingQueue = true; setTimeout(async () => { @@ -151,7 +298,9 @@ const WebSocketManager = { this.ws.onclose = () => { state.wsConnected = false; updateConnectionStatus('disconnected'); - this.handleReconnect(); + if (!this.isPaused) { + this.handleReconnect(); + } }; this.ws.onerror = () => { @@ -160,29 +309,100 @@ const WebSocketManager = { }, startHealthCheck() { - setInterval(() => { + this.stopHealthCheck(); + + this.healthCheckInterval = setInterval(() => { + if (this.isPaused) return; + + const timeSinceLastMessage = Date.now() - this.lastMessageTime; + if (timeSinceLastMessage > 120000) { + console.log('WebSocket connection appears stale. Reconnecting...'); + this.cleanupConnection(); + this.connect(); + return; + } + if (!this.isConnected()) { this.handleReconnect(); } }, 30000); }, + stopHealthCheck() { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; + } + }, + handleReconnect() { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; } + if (this.isPaused) return; + this.reconnectAttempts++; if (this.reconnectAttempts <= this.maxReconnectAttempts) { const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1); + //console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); this.reconnectTimeout = setTimeout(() => this.connect(), delay); } else { updateConnectionStatus('error'); + //console.log('Maximum reconnection attempts reached. Will try again in 60 seconds.'); setTimeout(() => { this.reconnectAttempts = 0; this.connect(); }, 60000); } + }, + + cleanupConnection() { + if (this.ws) { + this.ws.onopen = null; + this.ws.onmessage = null; + this.ws.onclose = null; + this.ws.onerror = null; + if (this.ws.readyState === WebSocket.OPEN) { + try { + this.ws.close(1000, 'Cleanup'); + } catch (e) { + console.warn('Error closing WebSocket:', e); + } + } + this.ws = null; + } + }, + + pause() { + this.isPaused = true; + //console.log('WebSocket operations paused'); + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + }, + + resume() { + if (!this.isPaused) return; + this.isPaused = false; + //console.log('WebSocket operations resumed'); + this.lastMessageTime = Date.now(); + if (!this.isConnected()) { + this.reconnectAttempts = 0; + this.connect(); + } + }, + + cleanup() { + this.isPaused = true; + this.stopHealthCheck(); + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + this.cleanupConnection(); } }; @@ -226,13 +446,37 @@ const getStatusClass = (status) => { case 'Completed': return 'bg-green-300 text-black dark:bg-green-600 dark:text-white'; case 'Expired': + case 'Timed-out': return 'bg-gray-200 text-black dark:bg-gray-400 dark:text-white'; case 'Error': - return 'bg-red-300 text-black dark:bg-red-600 dark:text-white'; case 'Failed': return 'bg-red-300 text-black dark:bg-red-600 dark:text-white'; + case 'Failed, swiped': case 'Failed, refunded': return 'bg-gray-200 text-black dark:bg-gray-400 dark:text-red-500'; + case 'InProgress': + case 'Script coin locked': + case 'Scriptless coin locked': + case 'Script coin lock released': + case 'SendingInitialTx': + case 'SendingPaymentTx': + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + case 'Received': + case 'Exchanged script lock tx sigs msg': + case 'Exchanged script lock spend tx msg': + case 'Script tx redeemed': + case 'Scriptless tx redeemed': + case 'Scriptless tx recovered': + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + case 'Accepted': + case 'Request accepted': + return 'bg-green-300 text-black dark:bg-green-600 dark:text-white'; + case 'Delaying': + case 'Auto accept delay': + return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; + case 'Abandoned': + case 'Rejected': + return 'bg-red-300 text-black dark:bg-red-600 dark:text-white'; default: return 'bg-blue-300 text-black dark:bg-blue-500 dark:text-white'; } @@ -289,7 +533,6 @@ function hasActiveFilters() { function filterAndSortData(bids) { if (!Array.isArray(bids)) { - console.log('Invalid bids data:', bids); return []; } @@ -313,7 +556,7 @@ function filterAndSortData(bids) { const coinName = selectedOption?.textContent.trim(); if (coinName) { - const coinToMatch = state.currentTab === 'sent' ? bid.coin_from : bid.coin_to; + const coinToMatch = state.currentTab === 'sent' ? bid.coin_to : bid.coin_from; if (!coinMatches(coinToMatch, coinName)) { return false; } @@ -326,7 +569,7 @@ function filterAndSortData(bids) { const coinName = selectedOption?.textContent.trim(); if (coinName) { - const coinToMatch = state.currentTab === 'sent' ? bid.coin_to : bid.coin_from; + const coinToMatch = state.currentTab === 'sent' ? bid.coin_from : bid.coin_to; if (!coinMatches(coinToMatch, coinName)) { return false; } @@ -457,6 +700,7 @@ const IdentityManager = { retryDelay: 2000, maxRetries: 3, cacheTimeout: 5 * 60 * 1000, + maxCacheSize: 500, async getIdentityData(address) { if (!address) return { address: '' }; @@ -465,8 +709,12 @@ const IdentityManager = { if (cachedData) return { ...cachedData, address }; if (this.pendingRequests.has(address)) { - const pendingData = await this.pendingRequests.get(address); - return { ...pendingData, address }; + try { + const pendingData = await this.pendingRequests.get(address); + return { ...pendingData, address }; + } catch (error) { + this.pendingRequests.delete(address); + } } const request = this.fetchWithRetry(address); @@ -474,10 +722,14 @@ const IdentityManager = { try { const data = await request; + + this.trimCacheIfNeeded(); + this.cache.set(address, { data, timestamp: Date.now() }); + return { ...data, address }; } catch (error) { console.warn(`Error fetching identity for ${address}:`, error); @@ -490,6 +742,7 @@ const IdentityManager = { getCachedIdentity(address) { const cached = this.cache.get(address); if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) { + cached.timestamp = Date.now(); return cached.data; } if (cached) { @@ -498,9 +751,36 @@ const IdentityManager = { return null; }, + trimCacheIfNeeded() { + if (this.cache.size > this.maxCacheSize) { + + const entries = Array.from(this.cache.entries()); + const sortedByAge = entries.sort((a, b) => a[1].timestamp - b[1].timestamp); + + const toRemove = Math.ceil(this.maxCacheSize * 0.2); + for (let i = 0; i < toRemove && i < sortedByAge.length; i++) { + this.cache.delete(sortedByAge[i][0]); + } + console.log(`Trimmed identity cache: removed ${toRemove} oldest entries`); + } + }, + + clearCache() { + this.cache.clear(); + this.pendingRequests.clear(); + }, + async fetchWithRetry(address, attempt = 1) { try { - const response = await fetch(`/json/identities/${address}`); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(`/json/identities/${address}`, { + signal: controller.signal + }); + + clearTimeout(timeoutId); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return await response.json(); } catch (error) { @@ -615,9 +895,132 @@ const createIdentityTooltipContent = (identity) => { }; // Table +let tooltipIdsToCleanup = new Set(); + +const cleanupTooltips = () => { + if (window.TooltipManager) { + Array.from(tooltipIdsToCleanup).forEach(id => { + const element = document.getElementById(id); + if (element) { + element.remove(); + } + }); + tooltipIdsToCleanup.clear(); + } + forceTooltipDOMCleanup(); +}; + +const forceTooltipDOMCleanup = () => { + let foundCount = 0; + let removedCount = 0; + const allTooltipElements = document.querySelectorAll('[role="tooltip"], [id^="tooltip-"], .tippy-box, [data-tippy-root]'); + foundCount += allTooltipElements.length; + + allTooltipElements.forEach(element => { + + const isDetached = !document.body.contains(element) || + element.classList.contains('hidden') || + element.style.display === 'none'; + + if (element.id && element.id.startsWith('tooltip-')) { + const triggerId = element.id; + const triggerElement = document.querySelector(`[data-tooltip-target="${triggerId}"]`); + + if (!triggerElement || + !document.body.contains(triggerElement) || + triggerElement.classList.contains('hidden')) { + element.remove(); + removedCount++; + return; + } + } + + if (isDetached) { + try { + element.remove(); + removedCount++; + } catch (e) { + console.warn('Error removing detached tooltip:', e); + } + } + }); + + const tippyRoots = document.querySelectorAll('[data-tippy-root]'); + foundCount += tippyRoots.length; + + tippyRoots.forEach(element => { + const isOrphan = !element.children.length || + element.children[0].classList.contains('hidden') || + !document.body.contains(element); + + if (isOrphan) { + try { + element.remove(); + removedCount++; + } catch (e) { + console.warn('Error removing tippy root:', e); + } + } + }); + + const tippyBoxes = document.querySelectorAll('.tippy-box'); + foundCount += tippyBoxes.length; + tippyBoxes.forEach(element => { + if (!element.parentElement || !document.body.contains(element.parentElement)) { + try { + element.remove(); + removedCount++; + } catch (e) { + console.warn('Error removing tippy box:', e); + } + } + }); + + // Handle legacy tooltip elements + document.querySelectorAll('.tooltip').forEach(element => { + const isTrulyDetached = !element.parentElement || + !document.body.contains(element.parentElement) || + element.classList.contains('hidden'); + + if (isTrulyDetached) { + try { + element.remove(); + removedCount++; + } catch (e) { + console.warn('Error removing legacy tooltip:', e); + } + } + }); + + if (window.TooltipManager && window.TooltipManager.activeTooltips) { + window.TooltipManager.activeTooltips.forEach((instance, id) => { + const tooltipElement = document.getElementById(id.split('tooltip-trigger-')[1]); + const triggerElement = document.querySelector(`[data-tooltip-trigger-id="${id}"]`); + + if (!tooltipElement || !triggerElement || + !document.body.contains(tooltipElement) || + !document.body.contains(triggerElement)) { + if (instance?.[0]) { + try { + instance[0].destroy(); + } catch (e) { + console.warn('Error destroying tooltip instance:', e); + } + } + window.TooltipManager.activeTooltips.delete(id); + } + }); + } + if (removedCount > 0) { + // console.log(`Tooltip cleanup: found ${foundCount}, removed ${removedCount} detached tooltips`); + } +}; + const createTableRow = async (bid) => { const identity = await IdentityManager.getIdentityData(bid.addr_from); const uniqueId = `${bid.bid_id}_${Date.now()}`; + tooltipIdsToCleanup.add(`tooltip-identity-${uniqueId}`); + tooltipIdsToCleanup.add(`tooltip-status-${uniqueId}`); const timeColor = getTimeStrokeColor(bid.expire_at); return ` @@ -641,8 +1044,8 @@ const createTableRow = async (bid) => { <div class="flex items-center min-w-max"> <div class="relative" data-tooltip-target="tooltip-identity-${uniqueId}"> <a href="/identity/${bid.addr_from}" class="text-xs font-mono"> - <span class="mr-2"> - ${state.currentTab === 'sent' ? 'Out' : 'In'} + <span> + ${state.currentTab === 'sent' ? 'Out:' : 'In:'} </span> ${identity?.label || formatAddressSMSG(bid.addr_from)} </a> @@ -684,27 +1087,27 @@ const createTableRow = async (bid) => { </div> </td> - <!-- Status Column --> - <td class="p-3"> - <div class="flex justify-center" data-tooltip-target="tooltip-status-${uniqueId}"> - <span class="w-full xl:w-4/5 flex bold justify-center items-center px-2.5 py-1 rounded-full text-xs font-medium - ${getStatusClass(bid.bid_state)}"> - ${bid.bid_state} - </span> - </div> + <!-- Status Column --> + <td class="py-3 px-6"> + <div class="relative flex justify-center" data-tooltip-target="tooltip-status-${uniqueId}"> + <span class="w-full lg:w-7/8 xl:w-2/3 px-2.5 py-1 inline-flex items-center justify-center rounded-full text-xs font-medium bold ${getStatusClass(bid.bid_state)}"> + ${bid.bid_state} + </span> + </div> </td> <!-- Actions Column --> - <td class="py-3 pr-6 pl-3"> + <td class="py-3 pr-4 pl-3"> <div class="flex justify-center"> <a href="/bid/${bid.bid_id}" - class="inline-block w-24 py-2 px-3 text-center text-sm font-medium text-white bg-blue-500 rounded-lg hover:bg-blue-600 transition-colors"> + class="inline-block w-20 py-1 px-2 font-medium text-center text-sm rounded-md bg-blue-500 text-white border border-blue-500 hover:bg-blue-600 transition duration-200"> View Bid </a> </div> </td> </tr> - + + <!-- Tooltips --> <div id="tooltip-identity-${uniqueId}" role="tooltip" class="fixed z-50 py-3 px-4 text-sm font-medium text-white bg-gray-400 rounded-lg shadow-sm opacity-0 transition-opacity duration-300 tooltip dark:bg-gray-600 max-w-sm pointer-events-none"> ${createIdentityTooltipContent(identity)} @@ -734,30 +1137,78 @@ const updateTableContent = async (type) => { const tbody = elements[`${type}BidsBody`]; if (!tbody) return; + if (window.TooltipManager) { + window.TooltipManager.cleanup(); + } + + cleanupTooltips(); + forceTooltipDOMCleanup(); + + tooltipIdsToCleanup.clear(); + const filteredData = state.data[type]; const startIndex = (state.currentPage[type] - 1) * PAGE_SIZE; const endIndex = startIndex + PAGE_SIZE; + const currentPageData = filteredData.slice(startIndex, endIndex); - console.log('Updating table content:', { - type: type, - totalFilteredBids: filteredData.length, - currentPageBids: currentPageData.length, - startIndex: startIndex, - endIndex: endIndex - }); + //console.log('Updating table content:', { + // type: type, + // totalFilteredBids: filteredData.length, + // currentPageBids: currentPageData.length, + // startIndex: startIndex, + // endIndex: endIndex + //}); - if (currentPageData.length > 0) { - const rowPromises = currentPageData.map(bid => createTableRow(bid)); - const rows = await Promise.all(rowPromises); - tbody.innerHTML = rows.join(''); - initializeTooltips(); - } else { + try { + if (currentPageData.length > 0) { + const BATCH_SIZE = 10; + let allRows = []; + + for (let i = 0; i < currentPageData.length; i += BATCH_SIZE) { + const batch = currentPageData.slice(i, i + BATCH_SIZE); + const rowPromises = batch.map(bid => createTableRow(bid)); + const rows = await Promise.all(rowPromises); + allRows = allRows.concat(rows); + + if (i + BATCH_SIZE < currentPageData.length) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + } + + const scrollPosition = tbody.parentElement?.scrollTop || 0; + + tbody.innerHTML = allRows.join(''); + + if (tbody.parentElement && scrollPosition > 0) { + tbody.parentElement.scrollTop = scrollPosition; + } + + if (document.visibilityState === 'visible') { + + setTimeout(() => { + initializeTooltips(); + + setTimeout(() => { + forceTooltipDOMCleanup(); + }, 100); + }, 10); + } + } else { + tbody.innerHTML = ` + <tr> + <td colspan="8" class="text-center py-4 text-gray-500 dark:text-white"> + No ${type} bids found + </td> + </tr>`; + } + } catch (error) { + console.error('Error updating table content:', error); tbody.innerHTML = ` <tr> - <td colspan="8" class="text-center py-4 text-gray-500 dark:text-white"> - No ${type} bids found + <td colspan="8" class="text-center py-4 text-red-500"> + Error loading data. Please try refreshing. </td> </tr>`; } @@ -766,38 +1217,160 @@ const updateTableContent = async (type) => { }; const initializeTooltips = () => { - if (window.TooltipManager) { - window.TooltipManager.cleanup(); + if (!window.TooltipManager || document.hidden) { + return; + } + + window.TooltipManager.cleanup(); + + let selector = '#' + state.currentTab + ' [data-tooltip-target]'; + const tooltipTriggers = document.querySelectorAll(selector); + const tooltipCount = tooltipTriggers.length; + if (tooltipCount > 50) { + //console.log(`Optimizing ${tooltipCount} tooltips`); + const viewportMargin = 200; + const viewportTooltips = Array.from(tooltipTriggers).filter(trigger => { + const rect = trigger.getBoundingClientRect(); + return ( + rect.bottom >= -viewportMargin && + rect.top <= (window.innerHeight + viewportMargin) && + rect.right >= 0 && + rect.left <= window.innerWidth + ); + }); + + viewportTooltips.forEach(trigger => { + createTooltipForTrigger(trigger); + }); + + const offscreenTooltips = Array.from(tooltipTriggers).filter(t => !viewportTooltips.includes(t)); + + offscreenTooltips.forEach(trigger => { + const createTooltipOnHover = () => { + createTooltipForTrigger(trigger); + trigger.removeEventListener('mouseenter', createTooltipOnHover); + }; + + trigger.addEventListener('mouseenter', createTooltipOnHover); + }); + } else { - const tooltipTriggers = document.querySelectorAll('[data-tooltip-target]'); tooltipTriggers.forEach(trigger => { - const targetId = trigger.getAttribute('data-tooltip-target'); - const tooltipContent = document.getElementById(targetId); - - if (tooltipContent) { - window.TooltipManager.create(trigger, tooltipContent.innerHTML, { - placement: trigger.getAttribute('data-tooltip-placement') || 'top', - interactive: true, - animation: 'shift-away', - maxWidth: 400, - allowHTML: true, - offset: [0, 8], - zIndex: 50 - }); - } + createTooltipForTrigger(trigger); }); } }; +const createTooltipForTrigger = (trigger) => { + if (!trigger || !window.TooltipManager) return; + + const targetId = trigger.getAttribute('data-tooltip-target'); + const tooltipContent = document.getElementById(targetId); + + if (tooltipContent) { + window.TooltipManager.create(trigger, tooltipContent.innerHTML, { + placement: trigger.getAttribute('data-tooltip-placement') || 'top', + interactive: true, + animation: false, + maxWidth: 400, + allowHTML: true, + offset: [0, 8], + zIndex: 50, + delay: [200, 0], + appendTo: () => document.body + }); + } +}; + +function optimizeForLargeDatasets() { + if (state.data[state.currentTab]?.length > 50) { + + const simplifyTooltips = tooltipIdsToCleanup.size > 50; + + implementVirtualizedRows(); + + let scrollTimeout; + window.addEventListener('scroll', () => { + clearTimeout(scrollTimeout); + scrollTimeout = setTimeout(() => { + cleanupOffscreenTooltips(); + }, 150); + }, { passive: true }); + } +} + +function cleanupOffscreenTooltips() { + if (!window.TooltipManager) return; + + const selector = '#' + state.currentTab + ' [data-tooltip-target]'; + const tooltipTriggers = document.querySelectorAll(selector); + + const farOffscreenTriggers = Array.from(tooltipTriggers).filter(trigger => { + const rect = trigger.getBoundingClientRect(); + return (rect.bottom < -window.innerHeight * 2 || + rect.top > window.innerHeight * 3); + }); + + farOffscreenTriggers.forEach(trigger => { + const targetId = trigger.getAttribute('data-tooltip-target'); + if (targetId) { + const tooltipElement = document.getElementById(targetId); + if (tooltipElement) { + window.TooltipManager.destroy(trigger); + trigger.addEventListener('mouseenter', () => { + createTooltipForTrigger(trigger); + }, { once: true }); + } + } + }); +} + +function implementVirtualizedRows() { + const tbody = elements[`${state.currentTab}BidsBody`]; + if (!tbody) return; + + const tableRows = tbody.querySelectorAll('tr'); + if (tableRows.length < 30) return; + + Array.from(tableRows).forEach(row => { + const rect = row.getBoundingClientRect(); + const isVisible = ( + rect.bottom >= 0 && + rect.top <= window.innerHeight + ); + + if (!isVisible && (rect.bottom < -window.innerHeight || rect.top > window.innerHeight * 2)) { + const tooltipTriggers = row.querySelectorAll('[data-tooltip-target]'); + tooltipTriggers.forEach(trigger => { + if (window.TooltipManager) { + window.TooltipManager.destroy(trigger); + } + }); + } + }); +} + // Fetching +let activeFetchController = null; + const fetchBids = async () => { try { + if (activeFetchController) { + activeFetchController.abort(); + } + activeFetchController = new AbortController(); const endpoint = state.currentTab === 'sent' ? '/json/sentbids' : '/json/bids'; const withExpiredSelect = document.getElementById('with_expired'); const includeExpired = withExpiredSelect ? withExpiredSelect.value === 'true' : true; - console.log('Fetching bids, include expired:', includeExpired); + //console.log('Fetching bids, include expired:', includeExpired); + const timeoutId = setTimeout(() => { + if (activeFetchController) { + activeFetchController.abort(); + } + }, 30000); + const response = await fetch(endpoint, { method: 'POST', headers: { @@ -807,128 +1380,67 @@ const fetchBids = async () => { body: JSON.stringify({ sort_by: state.filters.sort_by || 'created_at', sort_dir: state.filters.sort_dir || 'desc', - with_expired: true, // Always fetch all bids + with_expired: true, state: state.filters.state ?? -1, with_extra_info: true - }) + }), + signal: activeFetchController.signal }); + + clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } let data = await response.json(); - console.log('Received raw data:', data.length, 'bids'); + //console.log('Received raw data:', data.length, 'bids'); state.filters.with_expired = includeExpired; - data = filterAndSortData(data); - - return data; + let processedData; + if (data.length > 500) { + processedData = await new Promise(resolve => { + setTimeout(() => { + const filtered = filterAndSortData(data); + resolve(filtered); + }, 10); + }); + } else { + processedData = filterAndSortData(data); + } + + return processedData; } catch (error) { - console.error('Error in fetchBids:', error); + if (error.name === 'AbortError') { + console.log('Fetch request was aborted'); + } else { + console.error('Error in fetchBids:', error); + } throw error; + } finally { + activeFetchController = null; } }; const updateBidsTable = async () => { if (state.isLoading) { - console.log('Already loading, skipping update'); + //console.log('Already loading, skipping update'); return; } try { - console.log('Starting updateBidsTable for tab:', state.currentTab); - console.log('Current filters:', state.filters); + //console.log('Starting updateBidsTable for tab:', state.currentTab); + //console.log('Current filters:', state.filters); state.isLoading = true; updateLoadingState(true); const bids = await fetchBids(); - console.log('Fetched bids:', bids.length); + //console.log('Fetched bids:', bids.length); - const filteredBids = bids.filter(bid => { - // State filter - if (state.filters.state !== -1) { - const allowedStates = STATE_MAP[state.filters.state] || []; - if (allowedStates.length > 0 && !allowedStates.includes(bid.bid_state)) { - return false; - } - } - - const now = Math.floor(Date.now() / 1000); - if (!state.filters.with_expired && bid.expire_at <= now) { - return false; - } - - let yourCoinMatch = true; - let theirCoinMatch = true; - - if (state.filters.coin_from !== 'any') { - const coinFromSelect = document.getElementById('coin_from'); - const selectedOption = coinFromSelect?.querySelector(`option[value="${state.filters.coin_from}"]`); - const coinName = selectedOption?.textContent.trim(); - - if (coinName) { - const coinToMatch = state.currentTab === 'sent' ? bid.coin_from : bid.coin_to; - yourCoinMatch = coinMatches(coinToMatch, coinName); - console.log('Your Coin filtering:', { - filterCoin: coinName, - bidCoin: coinToMatch, - matches: yourCoinMatch - }); - } - } - - if (state.filters.coin_to !== 'any') { - const coinToSelect = document.getElementById('coin_to'); - const selectedOption = coinToSelect?.querySelector(`option[value="${state.filters.coin_to}"]`); - const coinName = selectedOption?.textContent.trim(); - - if (coinName) { - const coinToMatch = state.currentTab === 'sent' ? bid.coin_to : bid.coin_from; - theirCoinMatch = coinMatches(coinToMatch, coinName); - console.log('Their Coin filtering:', { - filterCoin: coinName, - bidCoin: coinToMatch, - matches: theirCoinMatch - }); - } - } - - if (!yourCoinMatch || !theirCoinMatch) { - return false; - } - - if (state.filters.searchQuery) { - const searchStr = state.filters.searchQuery.toLowerCase(); - const matchesBidId = bid.bid_id.toLowerCase().includes(searchStr); - const matchesIdentity = bid.addr_from?.toLowerCase().includes(searchStr); - - const identity = IdentityManager.cache.get(bid.addr_from); - const label = identity?.data?.label || ''; - const matchesLabel = label.toLowerCase().includes(searchStr); - - if (!(matchesBidId || matchesIdentity || matchesLabel)) { - return false; - } - } - - return true; - }); - - console.log('Filtered bids:', filteredBids.length); - - filteredBids.sort((a, b) => { - const direction = state.filters.sort_dir === 'asc' ? 1 : -1; - if (state.filters.sort_by === 'created_at') { - return direction * (a.created_at - b.created_at); - } - return 0; - }); - - state.data[state.currentTab] = filteredBids; + state.data[state.currentTab] = bids; state.currentPage[state.currentTab] = 1; await updateTableContent(state.currentTab); @@ -952,15 +1464,15 @@ const updatePaginationControls = (type) => { const currentPageSpan = elements[`currentPage${type.charAt(0).toUpperCase() + type.slice(1)}`]; const bidsCount = elements[`${type}BidsCount`]; - console.log('Pagination controls update:', { - type: type, - totalBids: data.length, - totalPages: totalPages, - currentPage: state.currentPage[type] - }); + //console.log('Pagination controls update:', { + // type: type, + // totalBids: data.length, + // totalPages: totalPages, + // currentPage: state.currentPage[type] + //}); if (state.currentPage[type] > totalPages) { - state.currentPage[type] = totalPages; + state.currentPage[type] = totalPages > 0 ? totalPages : 1; } if (controls) { @@ -1092,7 +1604,7 @@ function setupFilterEventListeners() { const withExpiredSelect = document.getElementById('with_expired'); if (coinToSelect) { - coinToSelect.addEventListener('change', () => { + EventManager.add(coinToSelect, 'change', () => { state.filters.coin_to = coinToSelect.value; updateBidsTable(); updateCoinFilterImages(); @@ -1101,7 +1613,7 @@ function setupFilterEventListeners() { } if (coinFromSelect) { - coinFromSelect.addEventListener('change', () => { + EventManager.add(coinFromSelect, 'change', () => { state.filters.coin_from = coinFromSelect.value; updateBidsTable(); updateCoinFilterImages(); @@ -1110,7 +1622,7 @@ function setupFilterEventListeners() { } if (withExpiredSelect) { - withExpiredSelect.addEventListener('change', () => { + EventManager.add(withExpiredSelect, 'change', () => { state.filters.with_expired = withExpiredSelect.value === 'true'; updateBidsTable(); updateClearFiltersButton(); @@ -1119,7 +1631,7 @@ function setupFilterEventListeners() { const searchInput = document.getElementById('searchInput'); if (searchInput) { - searchInput.addEventListener('input', (event) => { + EventManager.add(searchInput, 'input', (event) => { if (searchTimeout) { clearTimeout(searchTimeout); } @@ -1133,228 +1645,11 @@ function setupFilterEventListeners() { } } -// Tabs -const switchTab = (tabId) => { - if (state.isLoading) return; - - state.currentTab = tabId === '#sent' ? 'sent' : 'received'; - - elements.sentContent.classList.add('hidden'); - elements.receivedContent.classList.add('hidden'); - - const targetPanel = document.querySelector(tabId); - if (targetPanel) { - targetPanel.classList.remove('hidden'); - } - - elements.tabButtons.forEach(tab => { - const selected = tab.dataset.tabsTarget === tabId; - tab.setAttribute('aria-selected', selected); - if (selected) { - tab.classList.add('bg-gray-100', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white'); - tab.classList.remove('hover:text-gray-600', 'hover:bg-gray-50', 'dark:hover:bg-gray-500'); - } else { - tab.classList.remove('bg-gray-100', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white'); - tab.classList.add('hover:text-gray-600', 'hover:bg-gray-50', 'dark:hover:bg-gray-500'); - } - }); - - updateBidsTable(); -}; - -const setupEventListeners = () => { - const filterControls = document.querySelector('.flex.flex-wrap.justify-center'); - if (filterControls) { - filterControls.addEventListener('submit', (e) => { - e.preventDefault(); - }); - } - - const applyFiltersBtn = document.getElementById('applyFilters'); - if (applyFiltersBtn) { - applyFiltersBtn.remove(); - } - - if (elements.tabButtons) { - elements.tabButtons.forEach(button => { - button.addEventListener('click', () => { - if (state.isLoading) return; - - const targetId = button.getAttribute('data-tabs-target'); - if (!targetId) return; - - // Update tab button styles - elements.tabButtons.forEach(tab => { - const isSelected = tab.getAttribute('data-tabs-target') === targetId; - tab.setAttribute('aria-selected', isSelected); - - if (isSelected) { - tab.classList.add('bg-gray-100', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white'); - tab.classList.remove('hover:text-gray-600', 'hover:bg-gray-50', 'dark:hover:bg-gray-500'); - } else { - tab.classList.remove('bg-gray-100', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white'); - tab.classList.add('hover:text-gray-600', 'hover:bg-gray-50', 'dark:hover:bg-gray-500'); - } - }); - - elements.sentContent.classList.toggle('hidden', targetId !== '#sent'); - elements.receivedContent.classList.toggle('hidden', targetId !== '#received'); - - state.currentTab = targetId === '#sent' ? 'sent' : 'received'; - state.currentPage[state.currentTab] = 1; - - updateBidsTable(); - }); - }); - } - - ['Sent', 'Received'].forEach(type => { - const lowerType = type.toLowerCase(); - - if (elements[`prevPage${type}`]) { - elements[`prevPage${type}`].addEventListener('click', () => { - if (state.isLoading) return; - if (state.currentPage[lowerType] > 1) { - state.currentPage[lowerType]--; - updateTableContent(lowerType); - updatePaginationControls(lowerType); - } - }); - } - - if (elements[`nextPage${type}`]) { - elements[`nextPage${type}`].addEventListener('click', () => { - if (state.isLoading) return; - const totalPages = Math.ceil(state.data[lowerType].length / PAGE_SIZE); - if (state.currentPage[lowerType] < totalPages) { - state.currentPage[lowerType]++; - updateTableContent(lowerType); - updatePaginationControls(lowerType); - } - }); - } - }); - - const searchInput = document.getElementById('searchInput'); - if (searchInput) { - searchInput.addEventListener('input', handleSearch); - } - - const coinToSelect = document.getElementById('coin_to'); - const coinFromSelect = document.getElementById('coin_from'); - - if (coinToSelect) { - coinToSelect.addEventListener('change', () => { - state.filters.coin_to = coinToSelect.value; - updateBidsTable(); - updateCoinFilterImages(); - }); - } - - if (coinFromSelect) { - coinFromSelect.addEventListener('change', () => { - state.filters.coin_from = coinFromSelect.value; - updateBidsTable(); - updateCoinFilterImages(); - }); - } - - const filterElements = { - stateSelect: document.getElementById('state'), - sortBySelect: document.getElementById('sort_by'), - sortDirSelect: document.getElementById('sort_dir'), - withExpiredSelect: document.getElementById('with_expired'), - clearFiltersBtn: document.getElementById('clearFilters') - }; - - if (filterElements.stateSelect) { - filterElements.stateSelect.addEventListener('change', () => { - const stateValue = parseInt(filterElements.stateSelect.value); - - state.filters.state = isNaN(stateValue) ? -1 : stateValue; - - console.log('State filter changed:', { - selectedValue: filterElements.stateSelect.value, - parsedState: state.filters.state - }); - - updateBidsTable(); - updateClearFiltersButton(); - }); - } - - [ - filterElements.sortBySelect, - filterElements.sortDirSelect, - filterElements.withExpiredSelect - ].forEach(element => { - if (element) { - element.addEventListener('change', () => { - updateBidsTable(); - updateClearFiltersButton(); - }); - } - }); - - if (filterElements.clearFiltersBtn) { - filterElements.clearFiltersBtn.addEventListener('click', () => { - if (filterElements.clearFiltersBtn.disabled) return; - clearFilters(); - }); - } - - initializeTooltips(); - - document.addEventListener('change', (event) => { - const target = event.target; - const filterForm = document.querySelector('.flex.flex-wrap.justify-center'); - - if (filterForm && filterForm.contains(target)) { - const formData = { - state: filterElements.stateSelect?.value, - sort_by: filterElements.sortBySelect?.value, - sort_dir: filterElements.sortDirSelect?.value, - with_expired: filterElements.withExpiredSelect?.value, - coin_from: coinFromSelect?.value, - coin_to: coinToSelect?.value, - searchQuery: searchInput?.value - }; - - localStorage.setItem('bidsTableSettings', JSON.stringify(formData)); - } - }); - - const savedSettings = localStorage.getItem('bidsTableSettings'); - if (savedSettings) { - const settings = JSON.parse(savedSettings); - - Object.entries(settings).forEach(([key, value]) => { - const element = document.querySelector(`[name="${key}"]`); - if (element) { - element.value = value; - } - }); - - state.filters = { - state: settings.state ? parseInt(settings.state) : -1, - sort_by: settings.sort_by || 'created_at', - sort_dir: settings.sort_dir || 'desc', - with_expired: settings.with_expired === 'true', - searchQuery: settings.searchQuery || '', - coin_from: settings.coin_from || 'any', - coin_to: settings.coin_to || 'any' - }; - } - - updateCoinFilterImages(); - updateClearFiltersButton(); -}; - const setupRefreshButtons = () => { ['Sent', 'Received'].forEach(type => { const refreshButton = elements[`refresh${type}Bids`]; if (refreshButton) { - refreshButton.addEventListener('click', async () => { + EventManager.add(refreshButton, 'click', async () => { const lowerType = type.toLowerCase(); if (state.isRefreshing) { @@ -1397,6 +1692,7 @@ const setupRefreshButtons = () => { } catch (error) { console.error(`Error refreshing ${type} bids:`, error); } finally { + state.isRefreshing = false; state.isLoading = false; updateLoadingState(false); } @@ -1405,8 +1701,247 @@ const setupRefreshButtons = () => { }); }; +// Tabs +const switchTab = (tabId) => { + if (state.isLoading) return; + + if (window.TooltipManager) { + window.TooltipManager.cleanup(); + } + + cleanupTooltips(); + forceTooltipDOMCleanup(); + + tooltipIdsToCleanup.clear(); + + state.currentTab = tabId === '#sent' ? 'sent' : 'received'; + + elements.sentContent.classList.add('hidden'); + elements.receivedContent.classList.add('hidden'); + + const targetPanel = document.querySelector(tabId); + if (targetPanel) { + targetPanel.classList.remove('hidden'); + } + + elements.tabButtons.forEach(tab => { + const selected = tab.dataset.tabsTarget === tabId; + tab.setAttribute('aria-selected', selected); + if (selected) { + tab.classList.add('bg-gray-100', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white'); + tab.classList.remove('hover:text-gray-600', 'hover:bg-gray-50', 'dark:hover:bg-gray-500'); + } else { + tab.classList.remove('bg-gray-100', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white'); + tab.classList.add('hover:text-gray-600', 'hover:bg-gray-50', 'dark:hover:bg-gray-500'); + } + }); + setTimeout(() => { + updateBidsTable(); + }, 10); +}; + +const setupEventListeners = () => { + const filterControls = document.querySelector('.flex.flex-wrap.justify-center'); + if (filterControls) { + EventManager.add(filterControls, 'submit', (e) => { + e.preventDefault(); + }); + } + + const applyFiltersBtn = document.getElementById('applyFilters'); + if (applyFiltersBtn) { + applyFiltersBtn.remove(); + } + + if (elements.tabButtons) { + elements.tabButtons.forEach(button => { + EventManager.add(button, 'click', () => { + if (state.isLoading) return; + + const targetId = button.getAttribute('data-tabs-target'); + if (!targetId) return; + + elements.tabButtons.forEach(tab => { + const isSelected = tab.getAttribute('data-tabs-target') === targetId; + tab.setAttribute('aria-selected', isSelected); + + if (isSelected) { + tab.classList.add('bg-gray-100', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white'); + tab.classList.remove('hover:text-gray-600', 'hover:bg-gray-50', 'dark:hover:bg-gray-500'); + } else { + tab.classList.remove('bg-gray-100', 'dark:bg-gray-600', 'text-gray-900', 'dark:text-white'); + tab.classList.add('hover:text-gray-600', 'hover:bg-gray-50', 'dark:hover:bg-gray-500'); + } + }); + + elements.sentContent.classList.toggle('hidden', targetId !== '#sent'); + elements.receivedContent.classList.toggle('hidden', targetId !== '#received'); + + state.currentTab = targetId === '#sent' ? 'sent' : 'received'; + state.currentPage[state.currentTab] = 1; + + if (window.TooltipManager) { + window.TooltipManager.cleanup(); + } + cleanupTooltips(); + + updateBidsTable(); + }); + }); + } + + ['Sent', 'Received'].forEach(type => { + const lowerType = type.toLowerCase(); + + if (elements[`prevPage${type}`]) { + EventManager.add(elements[`prevPage${type}`], 'click', () => { + if (state.isLoading) return; + if (state.currentPage[lowerType] > 1) { + state.currentPage[lowerType]--; + updateTableContent(lowerType); + } + }); + } + + if (elements[`nextPage${type}`]) { + EventManager.add(elements[`nextPage${type}`], 'click', () => { + if (state.isLoading) return; + const totalPages = Math.ceil(state.data[lowerType].length / PAGE_SIZE); + if (state.currentPage[lowerType] < totalPages) { + state.currentPage[lowerType]++; + updateTableContent(lowerType); + } + }); + } + }); + + const searchInput = document.getElementById('searchInput'); + if (searchInput) { + EventManager.add(searchInput, 'input', handleSearch); + } + + const coinToSelect = document.getElementById('coin_to'); + const coinFromSelect = document.getElementById('coin_from'); + + if (coinToSelect) { + EventManager.add(coinToSelect, 'change', () => { + state.filters.coin_to = coinToSelect.value; + updateBidsTable(); + updateCoinFilterImages(); + }); + } + + if (coinFromSelect) { + EventManager.add(coinFromSelect, 'change', () => { + state.filters.coin_from = coinFromSelect.value; + updateBidsTable(); + updateCoinFilterImages(); + }); + } + + const filterElements = { + stateSelect: document.getElementById('state'), + sortBySelect: document.getElementById('sort_by'), + sortDirSelect: document.getElementById('sort_dir'), + withExpiredSelect: document.getElementById('with_expired'), + clearFiltersBtn: document.getElementById('clearFilters') + }; + + if (filterElements.stateSelect) { + EventManager.add(filterElements.stateSelect, 'change', () => { + const stateValue = parseInt(filterElements.stateSelect.value); + + state.filters.state = isNaN(stateValue) ? -1 : stateValue; + + console.log('State filter changed:', { + selectedValue: filterElements.stateSelect.value, + parsedState: state.filters.state + }); + + updateBidsTable(); + updateClearFiltersButton(); + }); + } + + [ + filterElements.sortBySelect, + filterElements.sortDirSelect, + filterElements.withExpiredSelect + ].forEach(element => { + if (element) { + EventManager.add(element, 'change', () => { + updateBidsTable(); + updateClearFiltersButton(); + }); + } + }); + + if (filterElements.clearFiltersBtn) { + EventManager.add(filterElements.clearFiltersBtn, 'click', () => { + if (filterElements.clearFiltersBtn.disabled) return; + clearFilters(); + }); + } + + EventManager.add(document, 'change', (event) => { + const target = event.target; + const filterForm = document.querySelector('.flex.flex-wrap.justify-center'); + + if (filterForm && filterForm.contains(target)) { + const formData = { + state: filterElements.stateSelect?.value, + sort_by: filterElements.sortBySelect?.value, + sort_dir: filterElements.sortDirSelect?.value, + with_expired: filterElements.withExpiredSelect?.value, + coin_from: coinFromSelect?.value, + coin_to: coinToSelect?.value, + searchQuery: searchInput?.value + }; + + localStorage.setItem('bidsTableSettings', JSON.stringify(formData)); + } + }); + + EventManager.add(window, 'scroll', () => { + if (!document.hidden && !state.isLoading) { + setTimeout(initializeTooltips, 100); + } + }, { passive: true }); + initializeTooltips(); + updateCoinFilterImages(); + updateClearFiltersButton(); +}; + +function setupMemoryMonitoring() { + const MEMORY_CHECK_INTERVAL = 2 * 60 * 1000; + + const intervalId = setInterval(() => { + if (document.hidden) { + console.log('Tab hidden - running memory optimization'); + IdentityManager.trimCacheIfNeeded(); + if (window.TooltipManager) { + window.TooltipManager.cleanup(); + } + if (state.data.sent.length > 1000) { + console.log('Trimming sent bids data'); + state.data.sent = state.data.sent.slice(0, 1000); + } + + if (state.data.received.length > 1000) { + console.log('Trimming received bids data'); + state.data.received = state.data.received.slice(0, 1000); + } + } else { + cleanupTooltips(); + } + }, MEMORY_CHECK_INTERVAL); + document.addEventListener('beforeunload', () => { + clearInterval(intervalId); + }, { once: true }); +} + // Init -document.addEventListener('DOMContentLoaded', () => { +function initialize() { const filterElements = { stateSelect: document.getElementById('state'), sortBySelect: document.getElementById('sort_by'), @@ -1423,13 +1958,31 @@ document.addEventListener('DOMContentLoaded', () => { if (filterElements.coinFrom) filterElements.coinFrom.value = 'any'; if (filterElements.coinTo) filterElements.coinTo.value = 'any'; - WebSocketManager.initialize(); - setupEventListeners(); - setupRefreshButtons(); - setupFilterEventListeners(); + setupMemoryMonitoring(); - updateClearFiltersButton(); - state.currentTab = 'sent'; - state.filters.state = -1; - updateBidsTable(); -}); + setTimeout(() => { + WebSocketManager.initialize(); + setupEventListeners(); + }, 10); + + setTimeout(() => { + setupRefreshButtons(); + setupFilterEventListeners(); + updateCoinFilterImages(); + }, 50); + + setTimeout(() => { + updateClearFiltersButton(); + state.currentTab = 'sent'; + state.filters.state = -1; + updateBidsTable(); + }, 100); + + window.cleanupBidsTable = cleanup; +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initialize); +} else { + initialize(); +} diff --git a/basicswap/templates/active.html b/basicswap/templates/active.html index c336938..d6e1c34 100644 --- a/basicswap/templates/active.html +++ b/basicswap/templates/active.html @@ -1,114 +1,118 @@ {% include 'header.html' %} -{% from 'style.html' import breadcrumb_line_svg, circular_arrows_svg %} -<div class="container mx-auto"> - <section class="p-5 mt-5"> - <div class="flex flex-wrap items-center -m-2"> - <div class="w-full md:w-1/2 p-2"> - <ul class="flex flex-wrap items-center gap-x-3 mb-2"> - <li> - <a class="flex font-medium text-xs text-coolGray-500 dark:text-gray-300 hover:text-coolGray-700" href="/"> - <p>Home</p> - </a> - </li> - <li>{{ breadcrumb_line_svg | safe }}</li> - <li> - <a class="flex font-medium text-xs text-coolGray-500 dark:text-gray-300 hover:text-coolGray-700" href="/active">Swaps In Progress</a> - </li> - </ul> - </div> - </div> - </section> - <section class="py-4"> - <div class="container px-4 mx-auto"> - <div class="relative py-11 px-16 bg-coolGray-900 dark:bg-blue-500 rounded-md overflow-hidden"> - <img class="absolute z-10 left-4 top-4" src="/static/images/elements/dots-red.svg" alt=""> - <img class="absolute z-10 right-4 bottom-4" src="/static/images/elements/dots-red.svg" alt=""> - <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=""> - <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">Swaps in Progress</h2> - <p class="font-normal text-coolGray-200 dark:text-white">Your swaps that are currently in progress.</p> - </div> - <div class="w-full md:w-1/2 p-3 p-6 container flex flex-wrap items-center justify-end items-center mx-auto"> - {% if refresh %} - <a id="refresh" href="/active" class="rounded-full flex flex-wrap justify-center px-5 py-3 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white border dark:bg-gray-500 dark:hover:bg-gray-700 border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none"> - {{ circular_arrows_svg | safe }} - <span>Refresh 30 seconds</span> - </a> - {% else %} - <a id="refresh" href="/active" class="flex flex-wrap justify-center px-5 py-4 bg-blue-500 hover:bg-blue-600 font-medium text-sm text-white borderdark:text-white dark:hover:text-white dark:bg-gray-600 dark:hover:bg-gray-700 dark:border-gray-600 dark:hover:border-gray-600 border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none"> - {{ circular_arrows_svg | safe }} - <span>Refresh</span> - </a> - {% endif %} - </div> +{% from 'style.html' import breadcrumb_line_svg, page_back_svg, page_forwards_svg, filter_clear_svg, filter_apply_svg, input_arrow_down_svg %} + +<section class="py-3 px-4 mt-6"> + <div class="lg:container mx-auto"> + <div class="relative py-8 px-8 bg-coolGray-900 dark:bg-blue-500 rounded-md overflow-hidden"> + <img class="absolute z-10 left-4 top-4" src="/static/images/elements/dots-red.svg" alt=""> + <img class="absolute z-10 right-4 bottom-4" src="/static/images/elements/dots-red.svg" alt=""> + <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=""> + <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-3 text-2xl font-bold text-white tracking-tighter">Swaps in Progress</h2> + <p class="font-normal text-coolGray-200 dark:text-white">Monitor your currently active swap transactions.</p> </div> </div> </div> - </section> - <section> - <div class="pl-6 pr-6 pt-0 pb-0 h-full overflow-hidden"> - <div class="pb-6 border-coolGray-100"> - <div class="flex flex-wrap items-center justify-between -m-2"> - <div class="w-full pt-2"> - <div class="container mt-5 mx-auto"> - <div class="pt-6 pb-8 bg-coolGray-100 dark:bg-gray-500 rounded-xl"> - <div class="px-6"> - <div class="w-full mt-6 pb-6 overflow-x-auto"> - <table class="w-full min-w-max text-sm"> - <thead class="uppercase"> - <tr class="text-left"> - <th class="p-0"> - <div class="py-3 px-6 rounded-tl-xl bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">Bid ID</span> - </div> - </th> - <th class="p-0"> - <div class="py-3 px-6 bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">Offer ID</span> - </div> - </th> - <th class="p-0"> - <div class="py-3 px-6 bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">Bid Status</span> - </div> - </th> - <th class="p-0"> - <div class="py-3 px-6 bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">ITX Status</span> - </div> - </th> - <th class="p-0"> - <div class="py-3 px-6 rounded-tr-xl bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">PTX Status</span> - </div> - </th> - </tr> - </thead> - {% for s in active_swaps %} - <tr class="opacity-100 text-gray-500 dark:text-gray-100 hover:bg-coolGray-200 dark:hover:bg-gray-600"> - <td class="py-3 px-6 monospace"> - <a href=/bid/{{ s[0] }}>{{ s[0]|truncate(50,true,'...',0) }}</a> - </td> - <td class="py-3 px-6 monospace"> - <a href=/offer/{{ s[1] }}>{{ s[1]|truncate(50,true,'...',0) }}</a> - </td> - <td class="py-3 px-6 w-52 whitespace-normal break-words">{{ s[2] }}</td> - <td class="py-3 px-6">{{ s[3] }}</td> - <td class="py-3 px-6">{{ s[4] }}</td> - </tr> - {% endfor %} - </table> + </div> +</section> + +{% include 'inc_messages.html' %} + +<section> + <div class="mt-5 lg:container mx-auto lg:px-0 px-6"> + <div class="pt-0 pb-6 bg-coolGray-100 dark:bg-gray-500 rounded-xl"> + <div class="px-0"> + <div class="w-auto mt-6 overflow-auto lg:overflow-hidden"> + <table class="w-full min-w-max"> + <thead class="uppercase"> + <tr> + <th class="p-0" data-sortable="true" data-column-index="0"> + <div class="py-3 pl-4 justify-center rounded-tl-xl bg-coolGray-200 dark:bg-gray-600"> + <span class="text-sm mr-1 text-gray-600 dark:text-gray-300 font-semibold"></span> </div> - </div> + </th> + <th class="p-0"> + <div class="py-3 pl-6 pr-3 justify-center bg-coolGray-200 dark:bg-gray-600"> + <span class="text-sm mr-1 text-gray-600 dark:text-gray-300 font-semibold">Time</span> + </div> + </th> + <th class="p-0 hidden xl:block"> + <div class="py-3 px-4 text-left bg-coolGray-200 dark:bg-gray-600"> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">Details</span> + </div> + </th> + <th class="p-0"> + <div class="py-3 px-4 bg-coolGray-200 dark:bg-gray-600 text-left"> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">You Send</span> + </div> + </th> + <th class="p-0"> + <div class="py-3 px-4 bg-coolGray-200 dark:bg-gray-600 text-center"> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">Swap</span> + </div> + </th> + <th class="p-0"> + <div class="py-3 px-4 bg-coolGray-200 dark:bg-gray-600 text-right"> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">You Receive</span> + </div> + </th> + <th class="p-0"> + <div class="py-3 px-4 bg-coolGray-200 dark:bg-gray-600 text-center"> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">Status</span> + </div> + </th> + <th class="p-0"> + <div class="py-3 px-4 bg-coolGray-200 dark:bg-gray-600 rounded-tr-xl"> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">Actions</span> + </div> + </th> + </tr> + </thead> + <tbody id="active-swaps-body"></tbody> + </table> + </div> + </div> + <div class="rounded-b-md"> + <div class="w-full"> + <div class="flex flex-wrap justify-between items-center pl-6 pt-6 pr-6 border-t border-gray-100 dark:border-gray-400"> + <div class="flex items-center"> + <div class="flex items-center mr-4"> + <span id="status-dot" class="w-2.5 h-2.5 rounded-full bg-gray-500 mr-2"></span> + <span id="status-text" class="text-sm text-gray-500">Connecting...</span> + </div> + <p class="text-sm font-heading dark:text-gray-400 mr-4">Active Swaps: <span id="activeSwapsCount">0</span></p> + {% if debug_ui_mode == true %} + <button type="button" id="refreshSwaps" class="inline-flex items-center px-4 py-2.5 font-medium text-sm text-white bg-blue-600 hover:bg-green-600 hover:border-green-600 rounded-lg transition duration-200 border border-blue-500 rounded-md shadow-button focus:ring-0 focus:outline-none"> + <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"></path> + </svg> + <span id="refreshText">Refresh</span> + </button> + {% endif %} + <div id="pagination-controls" class="flex items-center space-x-2" style="display: none;"> + <button id="prevPage" class="inline-flex items-center h-9 py-1 px-4 text-xs text-blue-50 font-semibold bg-blue-500 hover:bg-green-600 rounded-lg transition duration-200"> + <svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path> + </svg> + Previous + </button> + <p class="text-sm font-heading dark:text-white">Page <span id="currentPage">1</span></p> + <button id="nextPage" class="inline-flex items-center h-9 py-1 px-4 text-xs text-blue-50 font-semibold bg-blue-500 hover:bg-green-600 rounded-lg transition duration-200"> + Next + <svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path> + </svg> + </button> </div> </div> </div> </div> </div> </div> - </section> -</div> + </div> +</section> + +<script src="/static/js/active.js"></script> + {% include 'footer.html' %} -</body> -</html> diff --git a/basicswap/templates/bids.html b/basicswap/templates/bids.html index 29589be..1bf3d5d 100644 --- a/basicswap/templates/bids.html +++ b/basicswap/templates/bids.html @@ -1,9 +1,9 @@ {% include 'header.html' %} {% from 'style.html' import breadcrumb_line_svg, page_back_svg, page_forwards_svg, filter_clear_svg, filter_apply_svg, circular_arrows_svg, input_arrow_down_svg, arrow_right_svg %} -<div class="xl:container mx-auto"> - <section class="py-3 px-4 mt-6"> - <div class="xl:container mx-auto"> + +<section class="py-3 px-4 mt-6"> + <div class="lg:container mx-auto"> <div class="relative py-8 px-8 bg-coolGray-900 dark:bg-blue-500 rounded-md overflow-hidden"> <img class="absolute z-10 left-4 top-4" src="/static/images/elements/dots-red.svg" alt=""> <img class="absolute z-10 right-4 bottom-4" src="/static/images/elements/dots-red.svg" alt=""> @@ -20,7 +20,8 @@ {% include 'inc_messages.html' %} - <section> +<div class="xl:container mx-auto"> +<section> <div class="pl-6 pr-6 pt-0 mt-5 h-full overflow-hidden"> <div class="flex flex-wrap items-center justify-between -m-2"> <div class="w-full pt-2"> @@ -28,12 +29,12 @@ <ul class="flex flex-wrap text-sm font-medium text-center text-gray-500 dark:text-gray-400" id="myTab" data-tabs-toggle="#bidstab" role="tablist"> <li class="mr-2"> <button class="inline-block px-4 py-3 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white focus:outline-none focus:ring-0" id="sent-tab" data-tabs-target="#sent" type="button" role="tab" aria-controls="sent" aria-selected="true"> - Sent Bids ({{ sent_bids_count }}) + Sent Bids <span class="text-gray-500 dark:text-gray-400">({{ sent_bids_count }})</span> </button> </li> <li class="mr-2"> <button class="inline-block px-4 py-3 rounded-lg hover:text-gray-900 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white focus:outline-none focus:ring-0" id="received-tab" data-tabs-target="#received" type="button" role="tab" aria-controls="received" aria-selected="false"> - Received Bids ({{ received_bids_count }}) + Received Bids <span class="text-gray-500 dark:text-gray-400">({{ received_bids_count }})</span> </button> </li> </ul> @@ -41,7 +42,8 @@ </div> </div> </div> - </section> +</section> +</div> <section> <div class="px-6 py-0 h-full overflow-hidden"> @@ -52,7 +54,7 @@ <input type="text" id="searchInput" name="search" autocomplete="off" placeholder="Search bid ID, offer ID, address or label..." - class="w-full md:w-auto hover:border-blue-500 dark:hover:bg-gray-50 text-gray-900 pl-4 pr-10 dark:bg-gray-500 dark:text-white border border-gray-300 dark:border-gray-400 dark:text-gray-50 dark:placeholder-gray-400 text-sm rounded-lg outline-none block w-96 p-2.5 focus:ring-blue-500 focus:border-blue-500 focus:ring-0 dark:focus:bg-gray-500 dark:focus:text-white"> + class="w-full md:w-96 hover:border-blue-500 dark:hover:bg-gray-50 text-gray-900 pl-4 pr-10 dark:bg-gray-500 dark:text-white border border-gray-300 dark:border-gray-400 dark:text-gray-50 dark:placeholder-gray-400 text-sm rounded-lg outline-none block p-2.5 focus:ring-blue-500 focus:border-blue-500 focus:ring-0 dark:focus:bg-gray-500 dark:focus:text-white"> <div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"> <svg class="w-5 h-5 text-gray-500 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path> @@ -176,32 +178,32 @@ <tr class="text-left"> <th class="p-0"> <div class="py-3 pl-16 rounded-tl-xl bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">Date/Time</span> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">Date/Time</span> </div> </th> <th class="p-0 hidden lg:block"> <div class="p-3 bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">Details</span> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">Details</span> </div> </th> <th class="p-0"> <div class="p-3 bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">You Send</span> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">You Send</span> </div> </th> <th class="p-0"> <div class="p-3 bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">You Receive</span> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">You Receive</span> </div> </th> <th class="p-0"> <div class="p-3 text-center bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">Status</span> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">Status</span> </div> </th> <th class="p-0"> <div class="p-3 pr-6 text-center rounded-tr-xl bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">Actions</span> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">Actions</span> </div> </th> </tr> @@ -229,6 +231,14 @@ <span id="refreshSentText">Refresh</span> </button> {% endif %} + + <button id="exportSentBids" class="ml-4 inline-flex items-center px-4 py-2.5 font-medium text-sm text-white bg-green-600 hover:bg-green-700 hover:border-green-700 rounded-lg transition duration-200 border border-green-600 rounded-md shadow-button focus:ring-0 focus:outline-none"> + <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> + </svg> + <span>Export CSV</span> + </button> + </div> <div id="pagination-controls-sent" class="flex items-center space-x-2" style="display: none;"> <button id="prevPageSent" class="inline-flex items-center h-9 py-1 px-4 text-xs text-blue-50 font-semibold bg-blue-500 hover:bg-green-600 rounded-lg transition duration-200 focus:ring-0 focus:outline-none"> @@ -254,7 +264,6 @@ </div> </div> - <!-- Received Bids Tab --> <div class="hidden rounded-lg lg:px-6" id="received" role="tabpanel" aria-labelledby="received-tab"> <div id="received-content"> <div class="xl:container mx-auto lg:px-0"> @@ -266,32 +275,32 @@ <tr class="text-left"> <th class="p-0"> <div class="p-3 pl-16 rounded-tl-xl bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">Date/Time</span> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">Date/Time</span> </div> </th> <th class="p-0"> <div class="p-3 bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">Details</span> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">Details</span> </div> </th> <th class="p-0"> <div class="p-3 bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">You Send</span> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">You Send</span> </div> </th> <th class="p-0"> <div class="p-3 bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">You Receive</span> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">You Receive</span> </div> </th> <th class="p-0"> <div class="p-3 text-center bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">Status</span> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">Status</span> </div> </th> <th class="p-0"> <div class="p-3 pr-6 text-center rounded-tr-xl bg-coolGray-200 dark:bg-gray-600"> - <span class="text-xs text-gray-600 dark:text-gray-300 font-semibold">Actions</span> + <span class="text-sm text-gray-600 dark:text-gray-300 font-semibold">Actions</span> </div> </th> </tr> @@ -319,6 +328,14 @@ <span id="refreshReceivedText">Refresh</span> </button> {% endif %} + + <button id="exportReceivedBids" class="ml-4 inline-flex items-center px-4 py-2.5 font-medium text-sm text-white bg-green-600 hover:bg-green-700 hover:border-green-700 rounded-lg transition duration-200 border border-green-600 rounded-md shadow-button focus:ring-0 focus:outline-none"> + <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> + </svg> + <span>Export CSV</span> + </button> + </div> <div id="pagination-controls-received" class="flex items-center space-x-2" style="display: none;"> <button id="prevPageReceived" class="inline-flex items-center h-9 py-1 px-4 text-xs text-blue-50 font-semibold bg-blue-500 hover:bg-green-600 rounded-lg transition duration-200 focus:ring-0 focus:outline-none"> @@ -344,8 +361,8 @@ </div> </div> </div> -</div> <script src="/static/js/bids_sentreceived.js"></script> +<script src="/static/js/bids_export.js"></script> {% include 'footer.html' %}