/** * Debrid Services Comparison - Modern Refactored Version * Optimized for performance, maintainability, and modern web standards * @version 2.1.0 */ /* ============================================================================ CONFIGURATION & CONSTANTS ============================================================================ */ const CONFIG = Object.freeze({ API: { FILE_HOSTS: ['./json/file-hosts-optimized.json'], ADULT_HOSTS: ['./json/adult-hosts-optimized.json'], TIMEOUT: 8000, RETRY_ATTEMPTS: 2 }, PERFORMANCE: { DEBOUNCE_DELAY: 250, THROTTLE_DELAY: 100, RENDER_CHUNK_SIZE: 40, RENDER_IDLE_TIMEOUT: 160, SCROLL_THRESHOLD: 300 }, INTERSECTION: { THRESHOLD: 0.1, ROOT_MARGIN: '-40% 0px -50% 0px' }, ANIMATION: { DURATION: 250, EASING: 'cubic-bezier(0.4, 0, 0.2, 1)' }, CACHE: { DB_NAME: 'debrid-cache', DB_VERSION: 1, STORE_NAME: 'api-cache', DEFAULT_TTL: 3600000 // 1 hour in milliseconds } }); /* ============================================================================ COMPONENT LIFECYCLE MANAGEMENT ============================================================================ */ class ComponentLifecycle { #cleanupFns = []; #isDestroyed = false; /** * Register a cleanup function to be called when component is destroyed * @param {Function} fn - Cleanup function * @returns {Function} - Unregister function */ onDestroy(fn) { if (this.#isDestroyed) { console.warn('Cannot register cleanup on destroyed component'); return () => { }; } this.#cleanupFns.push(fn); // Return unregister function return () => { const index = this.#cleanupFns.indexOf(fn); if (index > -1) { this.#cleanupFns.splice(index, 1); } }; } /** * Destroy component and run all cleanup functions */ destroy() { if (this.#isDestroyed) { return; } this.#isDestroyed = true; this.#cleanupFns.forEach(fn => { try { fn(); } catch (error) { console.error('Error in cleanup function:', error); } }); this.#cleanupFns = []; } get isDestroyed() { return this.#isDestroyed; } } /* ============================================================================ EVENT BUS FOR DECOUPLED ARCHITECTURE ============================================================================ */ class EventBus { #events = new Map(); #maxListeners = 100; // Prevent memory leaks /** * Subscribe to an event * @param {string} event - Event name * @param {Function} callback - Event handler * @returns {Function} - Unsubscribe function */ on(event, callback) { 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); } /** * Unsubscribe from an event * @param {string} event - Event name * @param {Function} callback - Event handler to remove */ off(event, callback) { const listeners = this.#events.get(event); if (listeners) { listeners.delete(callback); if (listeners.size === 0) { this.#events.delete(event); } } } /** * Emit an event * @param {string} event - Event name * @param {*} data - Event data */ emit(event, data) { const listeners = this.#events.get(event); if (listeners) { [...listeners].forEach(callback => { try { callback(data); } catch (error) { console.error(`Error in event listener for "${event}":`, error); } }); } } /** * Subscribe to an event only once * @param {string} event - Event name * @param {Function} callback - Event handler * @returns {Function} - Unsubscribe function */ once(event, callback) { const wrappedCallback = (data) => { this.off(event, wrappedCallback); callback(data); }; return this.on(event, wrappedCallback); } /** * Clear all listeners */ clear() { this.#events.clear(); } /** * Get number of listeners for an event * @param {string} event - Event name * @returns {number} - Number of listeners */ listenerCount(event) { return this.#events.get(event)?.size || 0; } } // Global event bus instance const globalEventBus = new EventBus(); /* ============================================================================ INDEXEDDB CACHE SERVICE ============================================================================ */ class CacheService { static #dbPromise = null; /** * Open IndexedDB database * @returns {Promise} */ static async #openDB() { if (this.#dbPromise) { return this.#dbPromise; } this.#dbPromise = new Promise((resolve, reject) => { const request = indexedDB.open(CONFIG.CACHE.DB_NAME, CONFIG.CACHE.DB_VERSION); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains(CONFIG.CACHE.STORE_NAME)) { db.createObjectStore(CONFIG.CACHE.STORE_NAME); } }; }); return this.#dbPromise; } /** * Get cached value * @param {string} key - Cache key * @returns {Promise<*>} - Cached value or null */ static async get(key) { try { 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 = () => { const result = request.result; if (result) { // Check if expired if (result.expiresAt && result.expiresAt < Date.now()) { // Delete expired entry this.delete(key); resolve(null); } else { resolve(result.value); } } else { resolve(null); } }; request.onerror = () => reject(request.error); }); } catch (error) { console.error('Cache get error:', error); return null; } } /** * Set cached value * @param {string} key - Cache key * @param {*} value - Value to cache * @param {number} ttl - Time to live in milliseconds * @returns {Promise} */ static async set(key, value, ttl = CONFIG.CACHE.DEFAULT_TTL) { try { 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 }; return new Promise((resolve, reject) => { const request = store.put(cacheEntry, key); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } catch (error) { console.error('Cache set error:', error); } } /** * Delete cached value * @param {string} key - Cache key * @returns {Promise} */ static async delete(key) { try { 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(); request.onerror = () => reject(request.error); }); } catch (error) { console.error('Cache delete error:', error); } } /** * Clear all cached values * @returns {Promise} */ static async clear() { try { 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(); request.onerror = () => reject(request.error); }); } catch (error) { console.error('Cache clear error:', error); } } /** * Clean up expired cache entries * @returns {Promise} - Number of deleted entries */ static async cleanExpired() { try { 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; request.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const entry = cursor.value; if (entry.expiresAt && entry.expiresAt < Date.now()) { cursor.delete(); deletedCount++; } cursor.continue(); } else { resolve(deletedCount); } }; request.onerror = () => reject(request.error); }); } catch (error) { console.error('Cache cleanExpired error:', error); return 0; } } } /* ============================================================================ MEMOIZATION UTILITY ============================================================================ */ const memoize = (fn, options = {}) => { const cache = new Map(); const maxSize = options.maxSize || 100; const keyGenerator = options.keyGenerator || JSON.stringify; const memoized = function (...args) { const key = keyGenerator(args); if (cache.has(key)) { return cache.get(key); } const result = fn.apply(this, args); // Manage cache size if (cache.size >= maxSize) { const firstKey = cache.keys().next().value; cache.delete(firstKey); } cache.set(key, result); return result; }; memoized.cache = cache; memoized.clear = () => cache.clear(); return memoized; }; /* ============================================================================ UTILITIES MODULE ============================================================================ */ const Utils = (() => { // Performance-optimized debounce with WeakMap cache const debounceCache = new WeakMap(); const debounce = (func, wait = CONFIG.PERFORMANCE.DEBOUNCE_DELAY) => { let timeoutId; const debounced = function (...args) { clearTimeout(timeoutId); timeoutId = setTimeout(() => func.apply(this, args), wait); }; debounced.cancel = () => clearTimeout(timeoutId); return debounced; }; // RAF-based throttle for smooth animations 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; if (lastArgs) { func.apply(this, lastArgs); lastArgs = null; } }, limit); }); } else { lastArgs = args; } }; }; // Lazy fetch with timeout and abort controller const fetchWithTimeout = async (url, options = {}) => { 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); } }; // 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); return await response.json(); } catch (error) { errors.push({ url, error: error.message }); 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; } else if (key === 'dataset') { Object.assign(element.dataset, value); } else if (key.startsWith('on') && typeof value === 'function') { element.addEventListener(key.slice(2).toLowerCase(), value); } else { element.setAttribute(key, value); } }); children.forEach(child => { if (typeof child === 'string') { element.appendChild(document.createTextNode(child)); } else if (child instanceof Node) { element.appendChild(child); } }); return element; }; // Schedule work during idle time const scheduleIdleWork = (callback, options = {}) => { if ('requestIdleCallback' in window) { return requestIdleCallback(callback, { timeout: CONFIG.PERFORMANCE.RENDER_IDLE_TIMEOUT, ...options }); } return requestAnimationFrame(callback); }; // Cancel scheduled idle work const cancelIdleWork = (id) => { if ('cancelIdleCallback' in window) { cancelIdleCallback(id); } else { cancelAnimationFrame(id); } }; /** * Extract hostname from URL or text input * Handles various URL patterns and returns normalized hostname * @param {string} input - URL or text that might contain a URL * @returns {string|null} - Extracted and normalized hostname or null */ 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) if (normalized.match(/[a-z0-9-]+\.[a-z]{2,}/i)) { // Try to extract domain-like pattern const domainMatch = normalized.match(/([a-z0-9-]+\.(?:[a-z]{2,}\.)?[a-z]{2,})/i); if (domainMatch) { normalized = 'https://' + domainMatch[0]; } } else { // Not a URL, return the input as-is for regular search 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) { hostname = parts.slice(-3).join('.'); } } else { // Keep last 2 parts (domain + TLD) if (parts.length >= 2) { 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(/\/\/([^/:?#]+)/); if (manualMatch) { let hostname = manualMatch[1].toLowerCase(); hostname = hostname.replace(/^www\d*\./i, ''); const domainName = hostname.split('.')[0]; return domainName; } return null; } }; /** * Normalize hostname for fuzzy matching * Removes common variations and patterns */ const normalizeHostname = (hostname) => { if (!hostname) return ''; return hostname .toLowerCase() .replace(/[^a-z0-9]/g, '') // Remove non-alphanumeric .replace(/^(www|ftp|cdn|dl|download|file|files|upload|uploads)\d*/g, '') // Remove common prefixes .replace(/\d+$/g, ''); // Remove trailing numbers }; /** * Calculate similarity score between two strings - Memoized * Uses a combination of exact match, starts-with, and fuzzy matching */ 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; for (let i = 0; i < minLength; i++) { if (s1[i] === s2[i]) { matchingChars++; } else { 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)); }, { maxSize: 500, // Cache up to 500 similarity calculations keyGenerator: (args) => `${args[0]}:${args[1]}` }); /** * Levenshtein distance algorithm * Calculates the minimum number of edits needed to transform one string into another */ 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)) { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min( matrix[i - 1][j - 1] + 1, // substitution matrix[i][j - 1] + 1, // insertion matrix[i - 1][j] + 1 // deletion ); } } } return matrix[str2.length][str1.length]; }; return Object.freeze({ debounce, throttle, fetchWithTimeout, fetchWithFallback, createElement, scheduleIdleWork, cancelIdleWork, extractHostnameFromURL, normalizeHostname, calculateSimilarity }); })(); /* ============================================================================ DATA SERVICE LAYER ============================================================================ */ class DataService { static #cache = new Map(); /** * Fetch data with caching and fallback support * @param {string} type - Data type ('file-hosts' or 'adult-hosts') * @returns {Promise} - Fetched data */ static async fetchHosts(type) { const urls = type === 'file-hosts' ? CONFIG.API.FILE_HOSTS : CONFIG.API.ADULT_HOSTS; const cacheKey = `hosts-${type}`; // Check memory cache first if (this.#cache.has(cacheKey)) { return this.#cache.get(cacheKey); } // Check IndexedDB cache const cachedData = await CacheService.get(cacheKey); if (cachedData) { this.#cache.set(cacheKey, cachedData); return cachedData; } // 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); throw error; } } /** * Clear all cached data */ static clearCache() { this.#cache.clear(); return CacheService.clear(); } /** * Prefetch data for faster subsequent loads * @param {string} type - Data type */ static async prefetch(type) { try { await this.fetchHosts(type); } catch (error) { console.warn(`Prefetch failed for ${type}:`, error); } } } /* ============================================================================ ACCESSIBILITY UTILITIES ============================================================================ */ class A11yAnnouncer { static #announcer = null; /** * Initialize the announcer element */ static #init() { if (this.#announcer) return; this.#announcer = document.createElement('div'); this.#announcer.id = 'a11y-announcer'; this.#announcer.setAttribute('role', 'status'); 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', left: '-10000px', width: '1px', height: '1px', overflow: 'hidden' }); document.body.appendChild(this.#announcer); } /** * Announce a message to screen readers * @param {string} message - Message to announce * @param {string} priority - 'polite' or 'assertive' */ 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(() => { this.#announcer.textContent = message; }, 100); } } class FocusManager { static #focusStack = []; /** * Trap focus within a container * @param {HTMLElement} container - Container element * @returns {Function} - Cleanup function */ static trap(container) { 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 () => { }; const first = focusable[0]; const last = focusable[focusable.length - 1]; const handleKeyDown = (e) => { if (e.key !== 'Tab') return; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } } }; container.addEventListener('keydown', handleKeyDown); // Store previous focus this.#focusStack.push(document.activeElement); // Focus first element first.focus(); // Return cleanup function return () => { container.removeEventListener('keydown', handleKeyDown); this.release(); }; } /** * Release focus trap and restore previous focus */ static release() { const previousFocus = this.#focusStack.pop(); if (previousFocus && previousFocus.focus) { previousFocus.focus(); } } /** * Save current focus */ static save() { this.#focusStack.push(document.activeElement); } /** * Restore last saved focus */ static restore() { this.release(); } } class KeyboardNavigation { /** * Setup keyboard navigation for table * @param {HTMLElement} table - Table element * @returns {Function} - Cleanup function */ static setupTableNavigation(table) { let currentRow = -1; const tbody = table.querySelector('tbody'); if (!tbody) return () => { }; const getRows = () => Array.from(tbody.querySelectorAll('tr:not(.empty-state-row)')); const handleKeyDown = (e) => { const rows = getRows(); if (rows.length === 0) return; switch (e.key) { case 'ArrowDown': e.preventDefault(); currentRow = Math.min(currentRow + 1, rows.length - 1); rows[currentRow]?.focus(); break; case 'ArrowUp': e.preventDefault(); currentRow = Math.max(currentRow - 1, 0); rows[currentRow]?.focus(); break; case 'Home': e.preventDefault(); currentRow = 0; rows[0]?.focus(); break; case 'End': e.preventDefault(); currentRow = rows.length - 1; rows[currentRow]?.focus(); break; } }; // Make rows focusable const updateRowsFocusability = () => { getRows().forEach(row => { row.setAttribute('tabindex', '0'); }); }; updateRowsFocusability(); table.addEventListener('keydown', handleKeyDown); // Observer to update focusability when rows change const observer = new MutationObserver(updateRowsFocusability); observer.observe(tbody, { childList: true, subtree: true }); // Return cleanup function return () => { table.removeEventListener('keydown', handleKeyDown); observer.disconnect(); }; } } class ResponsiveTable { /** * Convert table to responsive layout based on viewport * @param {HTMLElement} table - Table element * @returns {Function} - Cleanup function */ static makeResponsive(table) { const wrapper = table.closest('.table-wrapper'); if (!wrapper) return () => { }; const checkResponsive = () => { const isMobile = window.innerWidth < 768; if (isMobile) { table.classList.add('table-responsive-mobile'); } else { table.classList.remove('table-responsive-mobile'); } }; checkResponsive(); const throttledCheck = Utils.throttle(checkResponsive, 150); window.addEventListener('resize', throttledCheck, { passive: true }); // Return cleanup function return () => { window.removeEventListener('resize', throttledCheck); }; } } /* ============================================================================ STATE MANAGEMENT ============================================================================ */ 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(); } } /* ============================================================================ LOADING MANAGER ============================================================================ */ class LoadingManager { #loaders = new Map(); #template = null; constructor() { this.#template = this.#createTemplate(); } #createTemplate() { const template = document.createElement('template'); template.innerHTML = `

`; return template; } show(target, text = 'Loading...') { 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)); } } /* ============================================================================ DATA TRANSFORMER ============================================================================ */ class DataTransformer { static transformIndexedData(data) { 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) => [ service, indices.includes(idx) ? '✅' : '❌' ]) ); }); return { isIndexed: true, data: transformed, services }; } static extractServices(data) { const firstHost = Object.values(data)[0]; return firstHost ? Object.keys(firstHost) : []; } } /* ============================================================================ TABLE MANAGER ============================================================================ */ class TableManager { #elements = {}; #state = null; #renderToken = null; #idleCallbackId = null; #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, initialLimit: 30, // Show only first 30 entries initially enableLazyLoad: true, // Enable lazy loading feature ...options }; this.#state = new StateManager({ currentData: {}, filteredData: {}, sort: { column: null, direction: 'asc' }, services: [], columns: [], 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()); this.#elements.searchInput.addEventListener('input', debouncedSearch); this.#lifecycle.onDestroy(() => { this.#elements.searchInput.removeEventListener('input', debouncedSearch); }); const handleKeydown = (e) => { if (e.key === 'Escape') this.#clearSearch(); }; this.#elements.searchInput.addEventListener('keydown', handleKeydown); this.#lifecycle.onDestroy(() => { this.#elements.searchInput.removeEventListener('keydown', handleKeydown); }); } if (this.#elements.clearIcon) { const handleClick = () => this.#clearSearch(); this.#elements.clearIcon.addEventListener('click', handleClick); this.#lifecycle.onDestroy(() => { this.#elements.clearIcon.removeEventListener('click', handleClick); }); } // Listen to global events const unsubDataLoad = globalEventBus.on('data-loaded', (data) => { this.generateTable(data); }); 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, filteredData: { ...data }, services: services || DataTransformer.extractServices(data), columns: DataTransformer.extractServices(data), 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, className: 'enhanced-table', role: 'table', 'aria-label': 'Service Comparison Table' }); // Create header const thead = Utils.createElement('thead'); 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(); } #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); // Render in chunks this.#renderChunked(tbody, entries, columns, token, true); } else { // Render in chunks 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); } else if (showLoadMore) { // After rendering limited entries, show the "Load All" button 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) { this.#elements.container.insertBefore(buttonContainer, wrapper.nextSibling); } else { this.#elements.container.appendChild(buttonContainer); } } #removeLoadAllButton() { if (this.#elements.loadAllBtn) { this.#elements.loadAllBtn.remove(); this.#elements.loadAllBtn = null; } } #loadAllEntries() { this.#state.update({ isFullyLoaded: true }); const btn = this.#elements.loadAllBtn?.querySelector('.load-all-btn'); if (btn) { btn.disabled = true; btn.innerHTML = `
Loading...`; } 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', { className: 'status-cell', role: 'gridcell', dataset: { status: status || '', 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'); if (sortableHeader && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); this.#handleSort({ target: sortableHeader }); } }); } #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' : '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(); } else { 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({ host, hostData, similarity }); } }); // 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]) ); } else { // Regular text search (exact substring matching) filtered = Object.fromEntries( Object.entries(currentData).filter(([host]) => host.toLowerCase().includes(searchTerm) ) ); } // 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 ? `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', { className: 'search-results', role: 'status', 'aria-live': 'polite' }); this.#elements.container.insertBefore(resultsElement, this.#elements.container.firstChild); } // Add URL indicator if it's a URL search if (isURLSearch) { resultsElement.innerHTML = ` ${message} `; } 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); } } /* ============================================================================ COMPARISON MANAGER ============================================================================ */ class ComparisonManager { #elements = {}; #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); } 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]); const statValue = Utils.createElement('span', { className: 'stat-value' }, [String(value)]); stat.appendChild(statLabel); 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 = ` Host ${service1} ${service2} Status `; table.appendChild(thead); // Body const tbody = Utils.createElement('tbody'); hosts.forEach(host => { const row = this.#createComparisonRow(host, service1, service2); if (row) { tbody.appendChild(row); } }); 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 if (hostData[serviceName] !== undefined) { return serviceName; } // Then try case-insensitive match const serviceKey = Object.keys(hostData).find( key => key.toLowerCase() === serviceName.toLowerCase() ); 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'; } else if (supported1) { statusClass = 'service1-only'; statusText = `${service1} only`; } else if (supported2) { 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', { className: `status-badge ${statusClass}` }, [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 if (hostData[serviceName] !== undefined) { return serviceName; } // Then try case-insensitive match return Object.keys(hostData).find( 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 = `

Select Two Services to Compare

Choose services from the dropdowns above to see a detailed comparison.

`; this.#elements.container.style.display = 'block'; } #showSameProviderWarning() { this.#elements.container.innerHTML = `
⚠️

Same Service Selected

Please select two different services to compare

`; this.#elements.container.style.display = 'block'; } } /* ============================================================================ UI FEATURES ============================================================================ */ 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) { entry.target.classList.add('animate-in'); observer.unobserve(entry.target); } }); }, { threshold: CONFIG.INTERSECTION.THRESHOLD, rootMargin: '0px 0px -50px 0px' }); elements.forEach(el => observer.observe(el)); }; // Header elevation on scroll 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 }); }; // Back-to-top button behavior const setupBackToTop = () => { const backToTop = document.getElementById('backToTop'); if (!backToTop) return; const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const updateVisibility = () => { backToTop.classList.toggle('visible', window.pageYOffset > 300); }; updateVisibility(); window.addEventListener('scroll', Utils.throttle(updateVisibility), { passive: true }); backToTop.addEventListener('click', () => { window.scrollTo({ top: 0, behavior: prefersReduced ? 'auto' : 'smooth' }); }); }; // 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'); } }); }, { threshold: CONFIG.INTERSECTION.THRESHOLD, rootMargin: CONFIG.INTERSECTION.ROOT_MARGIN }); sections.forEach(section => observer.observe(section)); }; // URL-based comparison const setupURLComparison = () => { 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; select1.dispatchEvent(new Event('change')); } }); } }; // Online/offline detection const setupOfflineDetection = () => { const showStatus = (isOnline) => { console.log(isOnline ? '✅ Connection restored' : '⚠️ You are offline'); }; window.addEventListener('online', () => showStatus(true)); window.addEventListener('offline', () => showStatus(false)); }; return Object.freeze({ setupScrollAnimations, setupHeaderElevation, setupBackToTop, setupNavHighlight, setupURLComparison, setupOfflineDetection }); })(); /* ============================================================================ PERFORMANCE MONITOR ============================================================================ */ 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`); } } /* ============================================================================ APPLICATION BOOTSTRAP ============================================================================ */ class App { #loadingManager = null; #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', 'host-search-input', '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); new ComparisonManager('compare-table-container', 'provider1', 'provider2', result.value); } else { adultHostsTable.generateTable(result.value); } } else { console.error(`Failed to load dataset ${index}:`, result.reason); const containerId = index === 0 ? '#file-hosts-table-container' : '#adult-hosts-table-container'; const container = document.querySelector(containerId); if (container) { container.innerHTML = `

Failed to load data. Please check your connection and refresh.

`; } } }); // Setup UI features UIFeatures.setupScrollAnimations(); UIFeatures.setupHeaderElevation(); UIFeatures.setupBackToTop(); 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()); this.#lifecycle.destroy(); } } /* ============================================================================ GLOBAL ERROR HANDLING ============================================================================ */ window.addEventListener('error', (event) => { console.error('❌ Global error:', event.error || event.message); }, { passive: true }); window.addEventListener('unhandledrejection', (event) => { console.error('❌ Unhandled promise rejection:', event.reason); }); /* ============================================================================ SERVICE WORKER REGISTRATION ============================================================================ */ if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker .register('/sw.js') .then(() => console.log('✅ Service worker registered')) .catch((error) => console.warn('⚠️ Service worker registration failed:', error)); }); } /* ============================================================================ INITIALIZE APPLICATION ============================================================================ */ document.addEventListener('DOMContentLoaded', () => { const app = new App(); app.init(); });