From 69bd6042798a279229fb35ed2b7d761fb835ef27 Mon Sep 17 00:00:00 2001 From: fynks <75840152+fynks@users.noreply.github.com> Date: Sun, 19 Apr 2026 14:26:12 +0500 Subject: [PATCH] fixed some bugs --- dist/js/app.js | 691 ++++++++++++++++++++++++------------------------- 1 file changed, 343 insertions(+), 348 deletions(-) diff --git a/dist/js/app.js b/dist/js/app.js index 0b65257..0a8afa9 100644 --- a/dist/js/app.js +++ b/dist/js/app.js @@ -52,11 +52,11 @@ class ComponentLifecycle { onDestroy(fn) { if (this.#isDestroyed) { console.warn('Cannot register cleanup on destroyed component'); - return () => {}; + return () => { }; } - + this.#cleanupFns.push(fn); - + // Return unregister function return () => { const index = this.#cleanupFns.indexOf(fn); @@ -73,7 +73,7 @@ class ComponentLifecycle { if (this.#isDestroyed) { return; } - + this.#isDestroyed = true; this.#cleanupFns.forEach(fn => { try { @@ -107,15 +107,15 @@ class EventBus { if (!this.#events.has(event)) { this.#events.set(event, new Set()); } - + const listeners = this.#events.get(event); - + if (listeners.size >= this.#maxListeners) { console.warn(`Event "${event}" has reached max listeners (${this.#maxListeners})`); } - + listeners.add(callback); - + // Return unsubscribe function return () => this.off(event, callback); } @@ -143,7 +143,7 @@ class EventBus { emit(event, data) { const listeners = this.#events.get(event); if (listeners) { - listeners.forEach(callback => { + [...listeners].forEach(callback => { try { callback(data); } catch (error) { @@ -161,8 +161,8 @@ class EventBus { */ once(event, callback) { const wrappedCallback = (data) => { - callback(data); this.off(event, wrappedCallback); + callback(data); }; return this.on(event, wrappedCallback); } @@ -229,7 +229,7 @@ class CacheService { const db = await this.#openDB(); const transaction = db.transaction(CONFIG.CACHE.STORE_NAME, 'readonly'); const store = transaction.objectStore(CONFIG.CACHE.STORE_NAME); - + return new Promise((resolve, reject) => { const request = store.get(key); request.onsuccess = () => { @@ -267,7 +267,7 @@ class CacheService { const db = await this.#openDB(); const transaction = db.transaction(CONFIG.CACHE.STORE_NAME, 'readwrite'); const store = transaction.objectStore(CONFIG.CACHE.STORE_NAME); - + const cacheEntry = { value, expiresAt: ttl ? Date.now() + ttl : null @@ -293,7 +293,7 @@ class CacheService { const db = await this.#openDB(); const transaction = db.transaction(CONFIG.CACHE.STORE_NAME, 'readwrite'); const store = transaction.objectStore(CONFIG.CACHE.STORE_NAME); - + return new Promise((resolve, reject) => { const request = store.delete(key); request.onsuccess = () => resolve(); @@ -313,7 +313,7 @@ class CacheService { const db = await this.#openDB(); const transaction = db.transaction(CONFIG.CACHE.STORE_NAME, 'readwrite'); const store = transaction.objectStore(CONFIG.CACHE.STORE_NAME); - + return new Promise((resolve, reject) => { const request = store.clear(); request.onsuccess = () => resolve(); @@ -333,7 +333,7 @@ class CacheService { const db = await this.#openDB(); const transaction = db.transaction(CONFIG.CACHE.STORE_NAME, 'readwrite'); const store = transaction.objectStore(CONFIG.CACHE.STORE_NAME); - + return new Promise((resolve, reject) => { const request = store.openCursor(); let deletedCount = 0; @@ -368,7 +368,7 @@ const memoize = (fn, options = {}) => { const maxSize = options.maxSize || 100; const keyGenerator = options.keyGenerator || JSON.stringify; - const memoized = function(...args) { + const memoized = function (...args) { const key = keyGenerator(args); if (cache.has(key)) { @@ -399,18 +399,14 @@ const memoize = (fn, options = {}) => { const Utils = (() => { // Performance-optimized debounce with WeakMap cache const debounceCache = new WeakMap(); - + const debounce = (func, wait = CONFIG.PERFORMANCE.DEBOUNCE_DELAY) => { - if (debounceCache.has(func)) return debounceCache.get(func); - let timeoutId; - const debounced = function executeDebouncedFunction(...args) { + const debounced = function (...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => func.apply(this, args), wait); }; - debounced.cancel = () => clearTimeout(timeoutId); - debounceCache.set(func, debounced); return debounced; }; @@ -418,12 +414,12 @@ const Utils = (() => { const throttle = (func, limit = CONFIG.PERFORMANCE.THROTTLE_DELAY) => { let waiting = false; let lastArgs = null; - + return function executeThrottledFunction(...args) { if (!waiting) { func.apply(this, args); waiting = true; - + requestAnimationFrame(() => { setTimeout(() => { waiting = false; @@ -444,17 +440,17 @@ const Utils = (() => { const { timeout = CONFIG.API.TIMEOUT, ...fetchOptions } = options; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); - + try { const response = await fetch(url, { ...fetchOptions, signal: controller.signal }); - + if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - + return response; } finally { clearTimeout(timeoutId); @@ -464,7 +460,7 @@ const Utils = (() => { // Cascade fetch with multiple fallback URLs const fetchWithFallback = async (urls, options = {}) => { const errors = []; - + for (const url of urls) { try { const response = await fetchWithTimeout(url, options); @@ -474,14 +470,14 @@ const Utils = (() => { continue; } } - + throw new Error(`All fetch attempts failed: ${JSON.stringify(errors)}`); }; // Efficient DOM element creation with attributes const createElement = (tag, attributes = {}, children = []) => { const element = document.createElement(tag); - + Object.entries(attributes).forEach(([key, value]) => { if (key === 'className') { element.className = value; @@ -493,7 +489,7 @@ const Utils = (() => { element.setAttribute(key, value); } }); - + children.forEach(child => { if (typeof child === 'string') { element.appendChild(document.createTextNode(child)); @@ -501,7 +497,7 @@ const Utils = (() => { element.appendChild(child); } }); - + return element; }; @@ -533,9 +529,9 @@ const Utils = (() => { */ const extractHostnameFromURL = (input) => { if (!input || typeof input !== 'string') return null; - + let normalized = input.trim(); - + // If it doesn't start with a protocol, try to detect if it's a URL if (!normalized.match(/^https?:\/\//i)) { // Check if it looks like a URL (contains dots and domain-like patterns) @@ -550,25 +546,25 @@ const Utils = (() => { return null; } } - + try { // Replace common typos in protocol normalized = normalized.replace(/^https?[:;.]+\/*/i, 'https://'); - + // Parse the URL const url = new URL(normalized); let hostname = url.hostname.toLowerCase(); - + // Remove 'www.' prefix hostname = hostname.replace(/^www\d*\./i, ''); - + // Handle subdomains - extract main domain const parts = hostname.split('.'); - + // If it's a known multi-level TLD (co.uk, com.au, etc.) const multiLevelTLDs = ['co.uk', 'com.au', 'co.nz', 'co.za', 'com.br', 'co.jp']; const lastTwoParts = parts.slice(-2).join('.'); - + if (multiLevelTLDs.includes(lastTwoParts)) { // Keep last 3 parts (domain + multi-level TLD) if (parts.length >= 3) { @@ -580,12 +576,12 @@ const Utils = (() => { hostname = parts.slice(-2).join('.'); } } - + // Extract just the domain name without TLD for searching const domainName = hostname.split('.')[0]; - + return domainName; - + } catch (error) { // If URL parsing fails, try to extract domain manually const manualMatch = normalized.match(/\/\/([^/:?#]+)/); @@ -595,7 +591,7 @@ const Utils = (() => { const domainName = hostname.split('.')[0]; return domainName; } - + return null; } }; @@ -606,7 +602,7 @@ const Utils = (() => { */ const normalizeHostname = (hostname) => { if (!hostname) return ''; - + return hostname .toLowerCase() .replace(/[^a-z0-9]/g, '') // Remove non-alphanumeric @@ -621,19 +617,19 @@ const Utils = (() => { const calculateSimilarity = memoize((str1, str2) => { const s1 = normalizeHostname(str1); const s2 = normalizeHostname(str2); - + if (!s1 || !s2) return 0; - + // Exact match if (s1 === s2) return 100; - + // One contains the other if (s1.includes(s2) || s2.includes(s1)) { const longer = Math.max(s1.length, s2.length); const shorter = Math.min(s1.length, s2.length); return Math.round((shorter / longer) * 95); } - + // Check if they start similarly const minLength = Math.min(s1.length, s2.length); let matchingChars = 0; @@ -644,15 +640,15 @@ const Utils = (() => { break; } } - + if (matchingChars >= 3) { return Math.round((matchingChars / Math.max(s1.length, s2.length)) * 85); } - + // Levenshtein-like distance for fuzzy matching const maxLength = Math.max(s1.length, s2.length); if (maxLength === 0) return 100; - + let distance = levenshteinDistance(s1, s2); return Math.max(0, Math.round((1 - distance / maxLength) * 80)); }, { @@ -666,15 +662,15 @@ const Utils = (() => { */ const levenshteinDistance = (str1, str2) => { const matrix = []; - + for (let i = 0; i <= str2.length; i++) { matrix[i] = [i]; } - + for (let j = 0; j <= str1.length; j++) { matrix[0][j] = j; } - + for (let i = 1; i <= str2.length; i++) { for (let j = 1; j <= str1.length; j++) { if (str2.charAt(i - 1) === str1.charAt(j - 1)) { @@ -688,7 +684,7 @@ const Utils = (() => { } } } - + return matrix[str2.length][str1.length]; }; @@ -736,11 +732,11 @@ class DataService { // Fetch from network try { const data = await Utils.fetchWithFallback(urls); - + // Cache in memory and IndexedDB this.#cache.set(cacheKey, data); await CacheService.set(cacheKey, data); - + return data; } catch (error) { console.error(`Failed to fetch ${type}:`, error); @@ -787,7 +783,7 @@ class A11yAnnouncer { this.#announcer.setAttribute('aria-live', 'polite'); this.#announcer.setAttribute('aria-atomic', 'true'); this.#announcer.className = 'visually-hidden'; - + // Ensure element is accessible but hidden Object.assign(this.#announcer.style, { position: 'absolute', @@ -807,9 +803,9 @@ class A11yAnnouncer { */ static announce(message, priority = 'polite') { this.#init(); - + this.#announcer.setAttribute('aria-live', priority); - + // Clear and set message with a slight delay for screen readers this.#announcer.textContent = ''; setTimeout(() => { @@ -830,8 +826,8 @@ class FocusManager { const focusable = container.querySelectorAll( 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])' ); - - if (focusable.length === 0) return () => {}; + + if (focusable.length === 0) return () => { }; const first = focusable[0]; const last = focusable[focusable.length - 1]; @@ -853,10 +849,10 @@ class FocusManager { }; container.addEventListener('keydown', handleKeyDown); - + // Store previous focus this.#focusStack.push(document.activeElement); - + // Focus first element first.focus(); @@ -901,7 +897,7 @@ class KeyboardNavigation { static setupTableNavigation(table) { let currentRow = -1; const tbody = table.querySelector('tbody'); - if (!tbody) return () => {}; + if (!tbody) return () => { }; const getRows = () => Array.from(tbody.querySelectorAll('tr:not(.empty-state-row)')); @@ -963,7 +959,7 @@ class ResponsiveTable { */ static makeResponsive(table) { const wrapper = table.closest('.table-wrapper'); - if (!wrapper) return () => {}; + if (!wrapper) return () => { }; const checkResponsive = () => { const isMobile = window.innerWidth < 768; @@ -975,7 +971,7 @@ class ResponsiveTable { }; checkResponsive(); - + const throttledCheck = Utils.throttle(checkResponsive, 150); window.addEventListener('resize', throttledCheck, { passive: true }); @@ -993,54 +989,54 @@ class StateManager { #state = {}; #listeners = new Map(); #lifecycle = null; - + constructor(initialState = {}) { this.#state = { ...initialState }; this.#lifecycle = new ComponentLifecycle(); } - + get(key) { return key ? this.#state[key] : { ...this.#state }; } - + set(key, value) { const oldValue = this.#state[key]; this.#state[key] = value; - + if (this.#listeners.has(key)) { this.#listeners.get(key).forEach(callback => { callback(value, oldValue); }); } - + return this; } - + update(updates) { Object.entries(updates).forEach(([key, value]) => { this.set(key, value); }); return this; } - + subscribe(key, callback) { if (!this.#listeners.has(key)) { this.#listeners.set(key, new Set()); } this.#listeners.get(key).add(callback); - + // Register cleanup const unsubscribe = () => this.#listeners.get(key)?.delete(callback); this.#lifecycle.onDestroy(unsubscribe); - + return unsubscribe; } - + reset() { this.#state = {}; this.#listeners.clear(); } - + destroy() { this.reset(); this.#lifecycle.destroy(); @@ -1053,11 +1049,11 @@ class StateManager { class LoadingManager { #loaders = new Map(); #template = null; - + constructor() { this.#template = this.#createTemplate(); } - + #createTemplate() { const template = document.createElement('template'); template.innerHTML = ` @@ -1074,50 +1070,50 @@ class LoadingManager { `; return template; } - + show(target, text = 'Loading...') { - const element = typeof target === 'string' - ? document.querySelector(target) + const element = typeof target === 'string' + ? document.querySelector(target) : target; - + if (!element) return null; - + const loaderId = `loader-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; const loader = this.#template.content.cloneNode(true).firstElementChild; - + loader.dataset.loaderId = loaderId; loader.querySelector('.loading-text').textContent = text; - + // Ensure parent has positioning context if (getComputedStyle(element).position === 'static') { element.style.position = 'relative'; } - + element.appendChild(loader); this.#loaders.set(loaderId, { element: loader, parent: element }); - + // Trigger animation requestAnimationFrame(() => { loader.classList.add('loading-overlay--visible'); }); - + return loaderId; } - + hide(loaderId) { const loaderData = this.#loaders.get(loaderId); if (!loaderData) return; - + const { element } = loaderData; element.classList.remove('loading-overlay--visible'); element.classList.add('loading-overlay--hiding'); - + setTimeout(() => { element.remove(); this.#loaders.delete(loaderId); }, CONFIG.ANIMATION.DURATION); } - + hideAll() { this.#loaders.forEach((_, loaderId) => this.hide(loaderId)); } @@ -1131,10 +1127,10 @@ class DataTransformer { if (!data?.services || !data?.supported) { return { isIndexed: false, data }; } - + const { services, supported } = data; const transformed = {}; - + Object.entries(supported).forEach(([host, indices]) => { transformed[host] = Object.fromEntries( services.map((service, idx) => [ @@ -1143,14 +1139,14 @@ class DataTransformer { ]) ); }); - + return { isIndexed: true, data: transformed, services }; } - + static extractServices(data) { const firstHost = Object.values(data)[0]; return firstHost ? Object.keys(firstHost) : []; @@ -1168,17 +1164,17 @@ class TableManager { #lifecycle = null; #keyboardNavigationCleanup = null; #responsiveCleanup = null; - + constructor(containerId, searchInputId, clearIconId, options = {}) { this.#lifecycle = new ComponentLifecycle(); - + this.#elements = { container: document.getElementById(containerId), searchInput: document.getElementById(searchInputId), clearIcon: document.getElementById(clearIconId), loadAllBtn: null }; - + this.options = { sortable: true, chunkSize: CONFIG.PERFORMANCE.RENDER_CHUNK_SIZE, @@ -1186,7 +1182,7 @@ class TableManager { enableLazyLoad: true, // Enable lazy loading feature ...options }; - + this.#state = new StateManager({ currentData: {}, filteredData: {}, @@ -1196,16 +1192,16 @@ class TableManager { isFullyLoaded: false, isSearchActive: false }); - + this.#init(); - + // Register cleanup this.#lifecycle.onDestroy(() => this.#cleanup()); } - + #init() { if (!this.#elements.container) return; - + // Bind event handlers if (this.#elements.searchInput) { const debouncedSearch = Utils.debounce(() => this.#performSearch()); @@ -1213,7 +1209,7 @@ class TableManager { this.#lifecycle.onDestroy(() => { this.#elements.searchInput.removeEventListener('input', debouncedSearch); }); - + const handleKeydown = (e) => { if (e.key === 'Escape') this.#clearSearch(); }; @@ -1222,7 +1218,7 @@ class TableManager { this.#elements.searchInput.removeEventListener('keydown', handleKeydown); }); } - + if (this.#elements.clearIcon) { const handleClick = () => this.#clearSearch(); this.#elements.clearIcon.addEventListener('click', handleClick); @@ -1237,45 +1233,45 @@ class TableManager { }); this.#lifecycle.onDestroy(unsubDataLoad); } - + #cleanup() { // Cancel any pending renders if (this.#idleCallbackId) { Utils.cancelIdleWork(this.#idleCallbackId); } - + // Cleanup keyboard navigation if (this.#keyboardNavigationCleanup) { this.#keyboardNavigationCleanup(); } - + // Cleanup responsive handler if (this.#responsiveCleanup) { this.#responsiveCleanup(); } - + // Destroy state this.#state?.destroy(); } - + destroy() { this.#lifecycle.destroy(); } - + generateTable(rawData) { if (!this.#elements.container || !rawData) { this.#showEmptyState('No data available'); return; } - + // Transform data if needed const { data, services } = DataTransformer.transformIndexedData(rawData); - + if (!Object.keys(data).length) { this.#showEmptyState('No data available'); return; } - + // Update state this.#state.update({ currentData: data, @@ -1285,14 +1281,14 @@ class TableManager { isFullyLoaded: false, isSearchActive: false }); - + this.#renderTable(); } - + #renderTable() { const { columns } = this.#state.get(); const tableId = this.#elements.container.id.replace('-container', ''); - + // Create table structure const table = Utils.createElement('table', { id: tableId, @@ -1300,90 +1296,95 @@ class TableManager { role: 'table', 'aria-label': 'Service Comparison Table' }); - + // Create header + const thead = Utils.createElement('thead'); - thead.innerHTML = this.#generateHeaderHTML(columns); + thead.appendChild(this.#generateHeaderRow(columns)); table.appendChild(thead); - + // Create body const tbody = Utils.createElement('tbody'); table.appendChild(tbody); - + // Create wrapper const wrapper = Utils.createElement('div', { className: 'table-wrapper' }, [table]); - + // Replace container content this.#elements.container.innerHTML = ''; this.#elements.container.appendChild(wrapper); - + // Attach event handlers this.#attachTableEvents(); - + // Setup accessibility features this.#keyboardNavigationCleanup = KeyboardNavigation.setupTableNavigation(table); this.#responsiveCleanup = ResponsiveTable.makeResponsive(table); - + // Render rows this.#renderTableBody(); } - - #generateHeaderHTML(columns) { - const generateHeaderCell = (label, dataColumn) => ` - - ${label} - - - `; - - const columnHeaders = columns - .map(col => generateHeaderCell(col, col)) - .join(''); - - return ` - - ${generateHeaderCell('Service Name', 'service')} - ${columnHeaders} - - `; + + #generateHeaderRow(columns) { + const row = Utils.createElement('tr'); + + const makeHeaderCell = (label, dataColumn) => { + const th = Utils.createElement('th', { + className: 'sortable', + tabindex: '0', + role: 'columnheader', + 'aria-sort': 'none', + dataset: { column: dataColumn } + }); + const span = Utils.createElement('span', {}, [label]); // text node — safe + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '12'); svg.setAttribute('height', '12'); + svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); svg.setAttribute('stroke-width', '2'); + svg.setAttribute('aria-hidden', 'true'); + svg.classList.add('sort-icon'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M12 5v14M5 12l7-7 7 7'); + svg.appendChild(path); + th.appendChild(span); + th.appendChild(svg); + return th; + }; + + row.appendChild(makeHeaderCell('Service Name', 'service')); + columns.forEach(col => row.appendChild(makeHeaderCell(col, col))); + return row; } - + #renderTableBody() { const table = this.#elements.container?.querySelector('table'); const tbody = table?.querySelector('tbody'); if (!tbody) return; - + const { filteredData, columns, isFullyLoaded, isSearchActive } = this.#state.get(); let entries = Object.entries(filteredData); - + // Cancel any pending render if (this.#idleCallbackId) { Utils.cancelIdleWork(this.#idleCallbackId); } - + // Generate unique render token const token = Symbol('render'); this.#renderToken = token; - + // Clear tbody tbody.textContent = ''; - + if (!entries.length) { this.#renderEmptyRow(tbody, columns.length + 1); return; } - + // Limit entries if not fully loaded and not searching const shouldLimit = this.options.enableLazyLoad && !isFullyLoaded && !isSearchActive; const displayLimit = shouldLimit ? this.options.initialLimit : entries.length; - + // If limiting, only show first N entries if (shouldLimit && entries.length > displayLimit) { entries = entries.slice(0, displayLimit); @@ -1394,26 +1395,26 @@ class TableManager { this.#renderChunked(tbody, entries, columns, token, false); } } - + #renderChunked(tbody, entries, columns, token, showLoadMore) { let index = 0; const chunkSize = this.options.chunkSize; - + const renderNextChunk = () => { // Check if render was cancelled if (this.#renderToken !== token) return; - + const fragment = document.createDocumentFragment(); const limit = Math.min(entries.length, index + chunkSize); - + for (; index < limit; index++) { const [host, hostData] = entries[index]; const row = this.#createTableRow(host, hostData, columns); fragment.appendChild(row); } - + tbody.appendChild(fragment); - + // Continue rendering if more entries exist if (index < entries.length) { this.#idleCallbackId = Utils.scheduleIdleWork(renderNextChunk); @@ -1422,45 +1423,45 @@ class TableManager { this.#showLoadAllButton(); } }; - + this.#idleCallbackId = Utils.scheduleIdleWork(renderNextChunk); } - + #showLoadAllButton() { // Remove existing button if any this.#removeLoadAllButton(); - + const { filteredData } = this.#state.get(); const totalEntries = Object.keys(filteredData).length; const displayedEntries = this.options.initialLimit; const remainingEntries = totalEntries - displayedEntries; - + // Create button container const buttonContainer = Utils.createElement('div', { className: 'load-all-container', style: 'text-align: center; padding: var(--space-2xl) 0; margin-top: var(--space-xl);' }); - + // Create button const loadAllBtn = Utils.createElement('button', { className: 'btn load-all-btn', type: 'button' }); - + loadAllBtn.innerHTML = ` Load All ${totalEntries} Hosts (${remainingEntries} more) `; - + loadAllBtn.addEventListener('click', () => this.#loadAllEntries(), { once: true }); - + buttonContainer.appendChild(loadAllBtn); - + // Store reference this.#elements.loadAllBtn = buttonContainer; - + // Insert after table wrapper const wrapper = this.#elements.container.querySelector('.table-wrapper'); if (wrapper && wrapper.nextSibling) { @@ -1469,69 +1470,63 @@ class TableManager { this.#elements.container.appendChild(buttonContainer); } } - + #removeLoadAllButton() { if (this.#elements.loadAllBtn) { this.#elements.loadAllBtn.remove(); this.#elements.loadAllBtn = null; } } - + #loadAllEntries() { - // Update state to show all entries this.#state.update({ isFullyLoaded: true }); - - // Show loading indicator on button + const btn = this.#elements.loadAllBtn?.querySelector('.load-all-btn'); if (btn) { btn.disabled = true; - btn.innerHTML = ` -
- Loading... - `; + btn.innerHTML = `
Loading...`; } - - // Re-render table body with all entries + setTimeout(() => { this.#removeLoadAllButton(); this.#renderTableBody(); }, 100); } - + #createTableRow(host, hostData, columns) { const row = Utils.createElement('tr', { role: 'row', dataset: { host: host.toLowerCase() } }); - + // Service name cell const serviceCell = this.#createServiceCell(host); row.appendChild(serviceCell); - + // Status cells columns.forEach(column => { const statusCell = this.#createStatusCell(hostData[column]); row.appendChild(statusCell); }); - + return row; } - + #createServiceCell(serviceName) { const cell = Utils.createElement('td', { className: 'service-cell', role: 'gridcell' }); - + const serviceInfo = Utils.createElement('div', { className: 'service-info' }); const nameSpan = Utils.createElement('span', { className: 'service-name' }, [serviceName]); - + serviceInfo.appendChild(nameSpan); cell.appendChild(serviceInfo); - + return cell; } - + #createStatusCell(status) { const isSupported = status === '✅'; const cell = Utils.createElement('td', { @@ -1542,40 +1537,40 @@ class TableManager { supported: isSupported ? 'true' : 'false' } }); - + const indicator = Utils.createElement('span', { className: 'status-indicator', 'aria-hidden': 'true', dataset: { supported: isSupported ? 'true' : 'false' } }, [isSupported ? '✅' : '❌']); - + const srText = Utils.createElement('span', { className: 'visually-hidden' }, [isSupported ? 'Supported' : 'Not supported']); - + cell.appendChild(indicator); cell.appendChild(srText); - + return cell; } - + #renderEmptyRow(tbody, colSpan) { const row = Utils.createElement('tr'); const cell = Utils.createElement('td', { className: 'empty-state-row', colSpan }, ['No services match your current filters.']); - + row.appendChild(cell); tbody.appendChild(row); } - + #attachTableEvents() { if (!this.options.sortable) return; - + const thead = this.#elements.container.querySelector('thead'); if (!thead) return; - + thead.addEventListener('click', (e) => this.#handleSort(e)); thead.addEventListener('keydown', (e) => { const sortableHeader = e.target.closest('.sortable'); @@ -1585,41 +1580,41 @@ class TableManager { } }); } - + #handleSort(event) { const header = event.target.closest('.sortable'); if (!header) return; - + const column = header.dataset.column; const { sort, currentData } = this.#state.get(); - + // Determine sort direction - const direction = sort.column === column && sort.direction === 'asc' - ? 'desc' + const direction = sort.column === column && sort.direction === 'asc' + ? 'desc' : 'asc'; - + // Update state this.#state.set('sort', { column, direction }); - + // Update ARIA attributes const allHeaders = this.#elements.container.querySelectorAll('[role="columnheader"]'); allHeaders.forEach(h => h.setAttribute('aria-sort', 'none')); header.setAttribute('aria-sort', direction === 'asc' ? 'ascending' : 'descending'); - + // Sort data const sortedData = this.#sortData(currentData, column, direction); this.#state.set('filteredData', sortedData); - + // Re-render this.#renderTableBody(); } - + #sortData(data, column, direction) { const entries = Object.entries(data); - + entries.sort(([hostA, dataA], [hostB, dataB]) => { let valueA, valueB; - + if (column === 'service') { valueA = hostA.toLowerCase(); valueB = hostB.toLowerCase(); @@ -1627,42 +1622,42 @@ class TableManager { valueA = dataA[column] === '✅' ? 1 : 0; valueB = dataB[column] === '✅' ? 1 : 0; } - + if (valueA < valueB) return direction === 'asc' ? -1 : 1; if (valueA > valueB) return direction === 'asc' ? 1 : -1; return 0; }); - + return Object.fromEntries(entries); } - + #performSearch() { const rawSearchTerm = (this.#elements.searchInput?.value || '').trim(); const { currentData } = this.#state.get(); - + if (rawSearchTerm) { let searchTerm = rawSearchTerm.toLowerCase(); let extractedHostname = null; let isURLSearch = false; - + // Try to extract hostname from URL extractedHostname = Utils.extractHostnameFromURL(rawSearchTerm); - + if (extractedHostname) { searchTerm = extractedHostname.toLowerCase(); isURLSearch = true; } - + // Perform search with fuzzy matching let filtered; - + if (isURLSearch) { // Use fuzzy matching for URL-based searches const matches = []; - + Object.entries(currentData).forEach(([host, hostData]) => { const similarity = Utils.calculateSimilarity(host, searchTerm); - + // Include if similarity is above threshold (60%) if (similarity >= 60) { matches.push({ @@ -1672,10 +1667,10 @@ class TableManager { }); } }); - + // Sort by similarity score (highest first) matches.sort((a, b) => b.similarity - a.similarity); - + // Convert back to object filtered = Object.fromEntries( matches.map(({ host, hostData }) => [host, hostData]) @@ -1688,63 +1683,63 @@ class TableManager { ) ); } - + // When searching, mark as search active and load all this.#state.update({ filteredData: filtered, isSearchActive: true, isFullyLoaded: true // Auto-load all when searching }); - + // Remove load all button if present this.#removeLoadAllButton(); - + if (this.#elements.clearIcon) { this.#elements.clearIcon.style.display = 'block'; } - + // Update search results message - const resultMessage = isURLSearch + const resultMessage = isURLSearch ? `Found ${Object.keys(filtered).length} host(s) matching "${extractedHostname}"` : `Showing ${Object.keys(filtered).length} of ${Object.keys(currentData).length} services`; - + this.#updateSearchResults(resultMessage, Object.keys(filtered).length, Object.keys(currentData).length, isURLSearch); } else { this.#clearSearch(); } - + this.#renderTableBody(); } - + #clearSearch() { if (this.#elements.searchInput) { this.#elements.searchInput.value = ''; } - + const { currentData } = this.#state.get(); - + // When clearing search, reset to limited view if was initially limited const shouldResetToLimited = this.options.enableLazyLoad; - + this.#state.update({ filteredData: { ...currentData }, isSearchActive: false, isFullyLoaded: shouldResetToLimited ? false : true }); - + if (this.#elements.clearIcon) { this.#elements.clearIcon.style.display = 'none'; } - + this.#updateSearchResults('', 0, 0); this.#renderTableBody(); } - + #updateSearchResults(message, filteredCount, totalCount, isURLSearch = false) { if (!this.#elements.container) return; - + let resultsElement = this.#elements.container.querySelector('.search-results'); - + if (message) { if (!resultsElement) { resultsElement = Utils.createElement('div', { @@ -1754,7 +1749,7 @@ class TableManager { }); this.#elements.container.insertBefore(resultsElement, this.#elements.container.firstChild); } - + // Add URL indicator if it's a URL search if (isURLSearch) { resultsElement.innerHTML = ` @@ -1767,26 +1762,26 @@ class TableManager { } else { resultsElement.textContent = message; } - + resultsElement.style.display = 'block'; - + // Announce to screen readers A11yAnnouncer.announce(message); } else if (resultsElement) { resultsElement.style.display = 'none'; } } - + #showEmptyState(message) { if (!this.#elements.container) return; - + const emptyState = Utils.createElement('div', { className: 'empty-state' }); - + const emptyMessage = Utils.createElement('p', {}, [message]); emptyState.appendChild(emptyMessage); - + this.#elements.container.innerHTML = ''; this.#elements.container.appendChild(emptyState); } @@ -1800,26 +1795,26 @@ class ComparisonManager { #data = null; #services = []; #lifecycle = null; - + constructor(containerId, select1Id, select2Id, data) { this.#lifecycle = new ComponentLifecycle(); - + this.#elements = { container: document.getElementById(containerId), select1: document.getElementById(select1Id), select2: document.getElementById(select2Id) }; - + const transformed = DataTransformer.transformIndexedData(data); this.#data = transformed.data; this.#services = transformed.services || DataTransformer.extractServices(transformed.data); - + this.#init(); } - + #init() { if (!this.#elements.select1 || !this.#elements.select2) return; - + // Clear existing options except the first placeholder option while (this.#elements.select1.options.length > 1) { this.#elements.select1.remove(1); @@ -1827,77 +1822,77 @@ class ComparisonManager { while (this.#elements.select2.options.length > 1) { this.#elements.select2.remove(1); } - + // Populate select dropdowns with service options this.#services.forEach(service => { const option1 = document.createElement('option'); option1.value = service; option1.textContent = service; this.#elements.select1.appendChild(option1); - + const option2 = document.createElement('option'); option2.value = service; option2.textContent = service; this.#elements.select2.appendChild(option2); }); - + const handleChange1 = () => this.#handleCompare(); const handleChange2 = () => this.#handleCompare(); - + this.#elements.select1.addEventListener('change', handleChange1); this.#elements.select2.addEventListener('change', handleChange2); - + // Register cleanup this.#lifecycle.onDestroy(() => { this.#elements.select1.removeEventListener('change', handleChange1); this.#elements.select2.removeEventListener('change', handleChange2); }); } - + destroy() { this.#lifecycle.destroy(); } - + #handleCompare() { const service1 = this.#elements.select1.value; const service2 = this.#elements.select2.value; - + console.log('Comparing services:', { service1, service2 }); - + if (!service1 || !service2) { this.#showEmptyState(); return; } - + if (service1 === service2) { this.#showSameProviderWarning(); return; } - + this.#renderComparison(service1, service2); } - + #renderComparison(service1, service2) { const hosts = Object.keys(this.#data); const stats = this.#calculateStats(service1, service2, hosts); - + const table = this.#createComparisonTable(service1, service2, hosts); const header = this.#createComparisonHeader(service1, service2, stats); - + this.#elements.container.innerHTML = ''; this.#elements.container.appendChild(header); this.#elements.container.appendChild(table); this.#elements.container.style.display = 'block'; } - + #createComparisonHeader(service1, service2, stats) { const header = Utils.createElement('div', { className: 'comparison-header' }); - + const title = Utils.createElement('h3', {}, [`Comparing ${service1} vs ${service2}`]); header.appendChild(title); - + const statsDiv = Utils.createElement('div', { className: 'comparison-stats' }); - + const createStatItem = (label, value) => { const stat = Utils.createElement('div', { className: 'stat' }); const statLabel = Utils.createElement('span', { className: 'stat-label' }, [label]); @@ -1906,20 +1901,20 @@ class ComparisonManager { stat.appendChild(statValue); return stat; }; - + statsDiv.appendChild(createStatItem('Shared', stats.shared)); statsDiv.appendChild(createStatItem(`${service1} Only`, stats.service1Only)); statsDiv.appendChild(createStatItem(`${service2} Only`, stats.service2Only)); - + header.appendChild(statsDiv); - + return header; } - + #createComparisonTable(service1, service2, hosts) { const wrapper = Utils.createElement('div', { className: 'table-wrapper' }); const table = Utils.createElement('table', { className: 'comparison-table' }); - + // Header const thead = Utils.createElement('thead'); thead.innerHTML = ` @@ -1931,7 +1926,7 @@ class ComparisonManager { `; table.appendChild(thead); - + // Body const tbody = Utils.createElement('tbody'); hosts.forEach(host => { @@ -1941,20 +1936,20 @@ class ComparisonManager { } }); table.appendChild(tbody); - + wrapper.appendChild(table); return wrapper; } - + #createComparisonRow(host, service1, service2) { const hostData = this.#data[host]; - + // Safety check: if hostData is undefined, skip this host if (!hostData) { console.warn(`Host data not found for: ${host}`); return null; } - + // Try to find the service keys with case-insensitive matching const findServiceKey = (serviceName) => { // First try exact match @@ -1967,16 +1962,16 @@ class ComparisonManager { ); return serviceKey; }; - + const serviceKey1 = findServiceKey(service1); const serviceKey2 = findServiceKey(service2); - + const supported1 = serviceKey1 ? hostData[serviceKey1] === '✅' : false; const supported2 = serviceKey2 ? hostData[serviceKey2] === '✅' : false; - + let statusClass = 'neither-supported'; let statusText = 'Neither'; - + if (supported1 && supported2) { statusClass = 'both-supported'; statusText = 'Both'; @@ -1987,24 +1982,24 @@ class ComparisonManager { statusClass = 'service2-only'; statusText = `${service2} only`; } - + const row = Utils.createElement('tr', { className: `comparison-row ${statusClass}`, dataset: { status: statusClass } }); - + // Host name const hostCell = Utils.createElement('td', { className: 'service-name' }, [host]); row.appendChild(hostCell); - + // Service 1 status const status1Cell = this.#createStatusCell(supported1); row.appendChild(status1Cell); - + // Service 2 status const status2Cell = this.#createStatusCell(supported2); row.appendChild(status2Cell); - + // Status text const statusTextCell = Utils.createElement('td', { className: 'status-text' }); const badge = Utils.createElement('span', { @@ -2012,37 +2007,37 @@ class ComparisonManager { }, [statusText]); statusTextCell.appendChild(badge); row.appendChild(statusTextCell); - + return row; } - + #createStatusCell(isSupported) { const cell = Utils.createElement('td', { className: `support-status ${isSupported ? 'supported' : 'not-supported'}`, dataset: { supported: isSupported ? 'true' : 'false' } }); - + const indicator = Utils.createElement('span', { className: 'status-indicator', 'aria-hidden': 'true', dataset: { supported: isSupported ? 'true' : 'false' } }, [isSupported ? '✅' : '❌']); - + const srText = Utils.createElement('span', { className: 'visually-hidden' }, [isSupported ? 'Supported' : 'Not supported']); - + cell.appendChild(indicator); cell.appendChild(srText); - + return cell; } - + #calculateStats(service1, service2, hosts) { let shared = 0; let service1Only = 0; let service2Only = 0; - + // Helper function to find service key with case-insensitive matching const findServiceKey = (hostData, serviceName) => { // First try exact match @@ -2054,25 +2049,25 @@ class ComparisonManager { key => key.toLowerCase() === serviceName.toLowerCase() ); }; - + hosts.forEach(host => { const hostData = this.#data[host]; if (!hostData) return; - + const serviceKey1 = findServiceKey(hostData, service1); const serviceKey2 = findServiceKey(hostData, service2); - + const supported1 = serviceKey1 ? hostData[serviceKey1] === '✅' : false; const supported2 = serviceKey2 ? hostData[serviceKey2] === '✅' : false; - + if (supported1 && supported2) shared++; else if (supported1) service1Only++; else if (supported2) service2Only++; }); - + return { shared, service1Only, service2Only }; } - + #showEmptyState() { this.#elements.container.innerHTML = `
@@ -2082,7 +2077,7 @@ class ComparisonManager { `; this.#elements.container.style.display = 'block'; } - + #showSameProviderWarning() { this.#elements.container.innerHTML = `
@@ -2102,10 +2097,10 @@ const UIFeatures = (() => { // Intersection Observer for scroll animations const setupScrollAnimations = () => { if (!('IntersectionObserver' in window)) return; - + const elements = document.querySelectorAll('.section, .status-card, .referral-card'); if (!elements.length) return; - + const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { @@ -2117,7 +2112,7 @@ const UIFeatures = (() => { threshold: CONFIG.INTERSECTION.THRESHOLD, rootMargin: '0px 0px -50px 0px' }); - + elements.forEach(el => observer.observe(el)); }; @@ -2125,13 +2120,13 @@ const UIFeatures = (() => { const setupHeaderElevation = () => { const header = document.querySelector('.site-header'); if (!header) return; - + const updateHeader = () => { header.classList.toggle('is-scrolled', window.scrollY > 8); }; - + updateHeader(); - + const throttledUpdate = Utils.throttle(updateHeader); window.addEventListener('scroll', throttledUpdate, { passive: true }); }; @@ -2161,23 +2156,23 @@ const UIFeatures = (() => { // Navigation highlight const setupNavHighlight = () => { if (!('IntersectionObserver' in window)) return; - + const links = Array.from(document.querySelectorAll('.header-nav .nav-link[href^="#"]')); if (!links.length) return; - + const sections = links .map(link => document.querySelector(link.getAttribute('href'))) .filter(Boolean); - + const linkMap = new Map( links.map(link => [link.getAttribute('href'), link]) ); - + const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { const id = `#${entry.target.id}`; const link = linkMap.get(id); - + if (link && entry.isIntersecting) { links.forEach(l => l.removeAttribute('aria-current')); link.setAttribute('aria-current', 'page'); @@ -2187,7 +2182,7 @@ const UIFeatures = (() => { threshold: CONFIG.INTERSECTION.THRESHOLD, rootMargin: CONFIG.INTERSECTION.ROOT_MARGIN }); - + sections.forEach(section => observer.observe(section)); }; @@ -2196,12 +2191,12 @@ const UIFeatures = (() => { const params = new URLSearchParams(window.location.search); const provider1 = params.get('compare'); const provider2 = params.get('with'); - + if (provider1 && provider2) { requestAnimationFrame(() => { const select1 = document.getElementById('provider1'); const select2 = document.getElementById('provider2'); - + if (select1 && select2) { select1.value = provider1; select2.value = provider2; @@ -2216,7 +2211,7 @@ const UIFeatures = (() => { const showStatus = (isOnline) => { console.log(isOnline ? '✅ Connection restored' : '⚠️ You are offline'); }; - + window.addEventListener('online', () => showStatus(true)); window.addEventListener('offline', () => showStatus(false)); }; @@ -2236,21 +2231,21 @@ const UIFeatures = (() => { ============================================================================ */ class PerformanceMonitor { #marks = new Map(); - + constructor() { this.mark('start'); } - + mark(name) { this.#marks.set(name, performance.now()); } - + measure(name, startMark = 'start') { const start = this.#marks.get(startMark) || 0; const end = performance.now(); return end - start; } - + log(name, startMark = 'start') { const duration = this.measure(name, startMark); console.log(`⏱️ ${name}: ${duration.toFixed(2)}ms`); @@ -2265,17 +2260,17 @@ class App { #performanceMonitor = null; #lifecycle = null; #tableManagers = []; - + constructor() { this.#loadingManager = new LoadingManager(); this.#performanceMonitor = new PerformanceMonitor(); this.#lifecycle = new ComponentLifecycle(); } - + async init() { try { this.#performanceMonitor.mark('dom-ready'); - + // Initialize tables const fileHostsTable = new TableManager( 'file-hosts-table-container', @@ -2283,32 +2278,32 @@ class App { 'clear-host-search' ); this.#tableManagers.push(fileHostsTable); - + const adultHostsTable = new TableManager( 'adult-hosts-table-container', 'adult-host-search-input', 'clear-adult-host-search' ); this.#tableManagers.push(adultHostsTable); - + // Show loaders const loaders = [ this.#loadingManager.show('#file-hosts-table-container', 'Loading file hosts...'), this.#loadingManager.show('#adult-hosts-table-container', 'Loading adult hosts...') ]; - + // Fetch data const dataPromises = [ Utils.fetchWithFallback(CONFIG.API.FILE_HOSTS), Utils.fetchWithFallback(CONFIG.API.ADULT_HOSTS) ]; - + const results = await Promise.allSettled(dataPromises); - + // Process results results.forEach((result, index) => { this.#loadingManager.hide(loaders[index]); - + if (result.status === 'fulfilled') { if (index === 0) { fileHostsTable.generateTable(result.value); @@ -2318,8 +2313,8 @@ class App { } } else { console.error(`Failed to load dataset ${index}:`, result.reason); - const containerId = index === 0 - ? '#file-hosts-table-container' + const containerId = index === 0 + ? '#file-hosts-table-container' : '#adult-hosts-table-container'; const container = document.querySelector(containerId); if (container) { @@ -2331,7 +2326,7 @@ class App { } } }); - + // Setup UI features UIFeatures.setupScrollAnimations(); UIFeatures.setupHeaderElevation(); @@ -2339,23 +2334,23 @@ class App { UIFeatures.setupNavHighlight(); UIFeatures.setupURLComparison(); UIFeatures.setupOfflineDetection(); - + this.#performanceMonitor.mark('fully-loaded'); this.#performanceMonitor.log('Application initialized', 'dom-ready'); - + // Clean up expired cache entries periodically CacheService.cleanExpired().then(count => { if (count > 0) { console.log(`🧹 Cleaned ${count} expired cache entries`); } }); - + } catch (error) { console.error('❌ Critical application error:', error); this.#loadingManager.hideAll(); } } - + destroy() { // Cleanup all table managers this.#tableManagers.forEach(manager => manager.destroy());