diff --git a/dist/js/app.js b/dist/js/app.js index ade2aa6..164b1ae 100644 --- a/dist/js/app.js +++ b/dist/js/app.js @@ -1,7 +1,7 @@ /** * Debrid Services Comparison - Modern Refactored Version * Optimized for performance, maintainability, and modern web standards - * @version 2.0.0 + * @version 2.1.0 */ /* ============================================================================ @@ -28,9 +28,371 @@ const CONFIG = Object.freeze({ 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) => { + callback(data); + this.off(event, wrappedCallback); + }; + 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 ============================================================================ */ @@ -253,10 +615,10 @@ const Utils = (() => { }; /** - * Calculate similarity score between two strings + * Calculate similarity score between two strings - Memoized * Uses a combination of exact match, starts-with, and fuzzy matching */ - const calculateSimilarity = (str1, str2) => { + const calculateSimilarity = memoize((str1, str2) => { const s1 = normalizeHostname(str1); const s2 = normalizeHostname(str2); @@ -293,7 +655,10 @@ const Utils = (() => { 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 @@ -341,15 +706,298 @@ const Utils = (() => { }); })(); +/* ============================================================================ + 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 response = await Utils.fetchWithFallback(urls); + const data = await response.json(); + + // 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) { @@ -382,13 +1030,22 @@ class StateManager { } this.#listeners.get(key).add(callback); - return () => this.#listeners.get(key)?.delete(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(); + } } /* ============================================================================ @@ -509,8 +1166,13 @@ class TableManager { #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), @@ -537,6 +1199,9 @@ class TableManager { }); this.#init(); + + // Register cleanup + this.#lifecycle.onDestroy(() => this.#cleanup()); } #init() { @@ -546,14 +1211,56 @@ class TableManager { if (this.#elements.searchInput) { const debouncedSearch = Utils.debounce(() => this.#performSearch()); this.#elements.searchInput.addEventListener('input', debouncedSearch); - this.#elements.searchInput.addEventListener('keydown', (e) => { + 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) { - this.#elements.clearIcon.addEventListener('click', () => this.#clearSearch()); + 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) { @@ -614,6 +1321,10 @@ class TableManager { // Attach event handlers this.#attachTableEvents(); + // Setup accessibility features + this.#keyboardNavigationCleanup = KeyboardNavigation.setupTableNavigation(table); + this.#responsiveCleanup = ResponsiveTable.makeResponsive(table); + // Render rows this.#renderTableBody(); } @@ -1059,6 +1770,9 @@ class TableManager { } resultsElement.style.display = 'block'; + + // Announce to screen readers + A11yAnnouncer.announce(message); } else if (resultsElement) { resultsElement.style.display = 'none'; } @@ -1086,8 +1800,11 @@ 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), @@ -1137,8 +1854,21 @@ class ComparisonManager { services: Object.keys(this.#data[host]) }))); - this.#elements.select1.addEventListener('change', () => this.#handleCompare()); - this.#elements.select2.addEventListener('change', () => this.#handleCompare()); + 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() { @@ -1541,10 +2271,13 @@ class PerformanceMonitor { class App { #loadingManager = null; #performanceMonitor = null; + #lifecycle = null; + #tableManagers = []; constructor() { this.#loadingManager = new LoadingManager(); this.#performanceMonitor = new PerformanceMonitor(); + this.#lifecycle = new ComponentLifecycle(); } async init() { @@ -1557,12 +2290,14 @@ class App { '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 = [ @@ -1616,11 +2351,24 @@ class App { 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(); + } } /* ============================================================================