diff --git a/dist/css/styles.css b/dist/css/styles.css index 065d90d..3704b1a 100644 --- a/dist/css/styles.css +++ b/dist/css/styles.css @@ -4507,4 +4507,347 @@ select:hover { .nav-link:hover, .btn:hover, .status-card:hover, .benefit-card:hover, .speed-card:hover, .referral-card:hover, .note-card:hover, .resource-item:hover { transform:none } - } \ No newline at end of file + }/* ===== ACCESSIBILITY ENHANCEMENTS ===== */ +.visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +#a11y-announcer { + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; +} + +/* Enhanced focus styles for keyboard navigation */ +*:focus-visible { + outline: 3px solid var(--primary); + outline-offset: 2px; + border-radius: var(--radius-sm); + transition: outline-offset 0.15s ease; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 3px solid var(--primary); + outline-offset: 3px; + box-shadow: 0 0 0 6px rgba(99, 102, 241, 0.15); +} + +/* Keyboard navigation for tables */ +table tbody tr:focus { + outline: 3px solid var(--primary); + outline-offset: -3px; + background-color: var(--bg-tertiary); + position: relative; + z-index: 1; +} + +table tbody tr:focus .service-name { + font-weight: var(--font-semibold); + color: var(--primary); +} + +table tbody tr[tabindex="0"] { + cursor: pointer; +} + +/* ===== RESPONSIVE TABLE ENHANCEMENTS ===== */ + +/* Mobile card layout */ +@media (max-width: 767px) { + .table-wrapper.mobile-view table { + display: block; + border: none; + } + + .table-wrapper.mobile-view thead { + display: none; + } + + .table-wrapper.mobile-view tbody { + display: block; + } + + .table-wrapper.mobile-view tr { + display: flex; + flex-direction: column; + margin-bottom: var(--space-xl); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: var(--space-lg); + background: var(--bg-elevated); + box-shadow: var(--shadow-sm); + transition: box-shadow 0.2s ease, transform 0.2s ease; + } + + .table-wrapper.mobile-view tr:hover, + .table-wrapper.mobile-view tr:focus { + box-shadow: var(--shadow-md); + transform: translateY(-2px); + } + + .table-wrapper.mobile-view td { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-sm) 0; + border: none; + border-bottom: 1px solid var(--border-primary); + } + + .table-wrapper.mobile-view td:last-child { + border-bottom: none; + } + + .table-wrapper.mobile-view td::before { + content: attr(data-label); + font-weight: var(--font-semibold); + color: var(--text-secondary); + margin-right: var(--space-md); + } + + .table-wrapper.mobile-view .service-cell { + font-size: var(--text-lg); + font-weight: var(--font-bold); + padding-bottom: var(--space-md); + border-bottom: 2px solid var(--border-primary) !important; + margin-bottom: var(--space-md); + justify-content: center; + } + + .table-wrapper.mobile-view .service-cell::before { + display: none; + } + + .table-wrapper.mobile-view .status-cell { + justify-content: flex-end; + } + + .table-wrapper.mobile-view .status-indicator { + font-size: var(--text-xl); + } +} + +/* Tablet optimizations */ +@media (min-width: 768px) and (max-width: 1024px) { + .table-wrapper { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + table { + min-width: 600px; + } + + th, td { + padding: var(--space-sm) var(--space-md); + } +} + +/* ===== ENHANCED LOADING STATES ===== */ +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; + filter: grayscale(0.3); +} + +.btn:disabled:hover { + transform: none; +} + +/* Loading spinner animation */ +.loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid var(--border-primary); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Search results animation */ +.search-results { + animation: slideDown 0.2s ease-out; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ===== IMPROVED TOUCH TARGETS ===== */ +@media (pointer: coarse) { + button, + a, + input, + select, + textarea { + min-height: 44px; + min-width: 44px; + } + + .nav-link { + padding: var(--space-lg) var(--space-xl); + } + + .btn { + padding: var(--space-lg) var(--space-2xl); + } + + table tbody tr { + min-height: 60px; + } +} + +/* ===== DARK MODE ADJUSTMENTS ===== */ +[data-theme="dark"] table tbody tr:focus { + background-color: rgba(99, 102, 241, 0.1); +} + +[data-theme="dark"] *:focus-visible { + outline-color: var(--primary-light); +} + +/* ===== REDUCED MOTION SUPPORT ===== */ +@media (prefers-reduced-motion: reduce) { + .search-results, + .table-wrapper.mobile-view tr { + animation: none !important; + transition: none !important; + } + + .loading-spinner { + animation-duration: 2s; + } + + table tbody tr:focus { + transition: none; + } +} + +/* ===== HIGH CONTRAST MODE ===== */ +@media (prefers-contrast: high) { + *:focus-visible { + outline-width: 4px; + outline-offset: 4px; + } + + table tbody tr:focus { + outline-width: 4px; + outline-offset: -4px; + border: 2px solid var(--primary); + } + + .btn { + border-width: 2px; + } +} + +/* ===== PRINT STYLES ===== */ +@media print { + .visually-hidden, + #a11y-announcer { + display: none !important; + } + + table tbody tr:focus { + outline: none; + background: transparent; + } +} + +/* ===== ERROR STATE IMPROVEMENTS ===== */ +.error-state { + text-align: center; + padding: var(--space-3xl); + color: var(--text-secondary); +} + +.error-state[role="alert"] { + border: 2px solid var(--error); + border-radius: var(--radius-lg); + background-color: rgba(239, 68, 68, 0.1); +} + +.error-state .btn { + margin-top: var(--space-lg); +} + +/* ===== LOAD ALL BUTTON ENHANCEMENTS ===== */ +.load-all-container { + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.load-all-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + transition: all 0.2s ease; +} + +.load-all-btn svg { + transition: transform 0.2s ease; +} + +.load-all-btn:hover:not(:disabled) svg { + transform: translateY(3px); +} + +/* ===== COMPARISON TABLE MOBILE IMPROVEMENTS ===== */ +@media (max-width: 767px) { + .comparison-table { + font-size: var(--text-sm); + } + + .comparison-header { + flex-direction: column; + text-align: center; + } + + .comparison-stats { + flex-direction: column; + width: 100%; + } + + .stat { + width: 100%; + padding: var(--space-md); + } +} diff --git a/dist/index.html b/dist/index.html index 41a2bdb..7b39687 100644 --- a/dist/index.html +++ b/dist/index.html @@ -425,7 +425,6 @@ - diff --git a/dist/js/app.js b/dist/js/app.js index 8ac2acd..89f7bde 100644 --- a/dist/js/app.js +++ b/dist/js/app.js @@ -1,7 +1,17 @@ /** * Debrid Services Comparison - Modern Refactored Version * Optimized for performance, maintainability, and modern web standards - * @version 2.0.0 + * @version 2.1.0 + * + * Improvements: + * - Memory leak prevention with lifecycle management + * - Web Workers for heavy operations + * - IndexedDB caching + * - Virtual scrolling for large datasets + * - Enhanced accessibility (WCAG 2.1 AA compliant) + * - Responsive design (mobile-first) + * - Memoization for expensive operations + * - Event bus for decoupled architecture */ /* ============================================================================ @@ -28,9 +38,382 @@ 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 + }, + VIRTUAL_SCROLL: { + ITEM_HEIGHT: 50, // Approximate row height in pixels + BUFFER_SIZE: 5, // Number of extra items to render above/below viewport + ENABLE_THRESHOLD: 100 // Enable virtual scrolling when items exceed this number } }); +/* ============================================================================ + 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('Attempted to 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('Cleanup function error:', 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(`EventBus: Max listeners (${this.#maxListeners}) reached for event "${event}"`); + } + + 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(`EventBus error in "${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)) { + const store = db.createObjectStore(CONFIG.CACHE.STORE_NAME, { keyPath: 'key' }); + store.createIndex('expires', 'expires', { unique: false }); + } + }; + }); + + 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); + const request = store.get(key); + + return new Promise((resolve, reject) => { + request.onsuccess = () => { + const record = request.result; + + if (!record) { + resolve(null); + return; + } + + // Check if expired + if (record.expires && record.expires < Date.now()) { + this.delete(key); + resolve(null); + return; + } + + resolve(record.value); + }; + request.onerror = () => reject(request.error); + }); + } catch (error) { + console.error('CacheService.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 record = { + key, + value, + expires: ttl ? Date.now() + ttl : null, + timestamp: Date.now() + }; + + const request = store.put(record); + + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } catch (error) { + console.error('CacheService.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); + const request = store.delete(key); + + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } catch (error) { + console.error('CacheService.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); + const request = store.clear(); + + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } catch (error) { + console.error('CacheService.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); + const index = store.index('expires'); + const range = IDBKeyRange.upperBound(Date.now()); + const request = index.openCursor(range); + + let deletedCount = 0; + + return new Promise((resolve, reject) => { + request.onsuccess = (event) => { + const cursor = event.target.result; + if (cursor) { + cursor.delete(); + deletedCount++; + cursor.continue(); + } else { + resolve(deletedCount); + } + }; + request.onerror = () => reject(request.error); + }); + } catch (error) { + console.error('CacheService.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 ============================================================================ */ @@ -116,6 +499,40 @@ const Utils = (() => { throw new Error(`All fetch attempts failed: ${JSON.stringify(errors)}`); }; + // Safe DOM manipulation using native browser APIs + const sanitizeHTML = (html) => { + // Create a temporary container + const temp = document.createElement('div'); + temp.textContent = html; // This escapes all HTML + return temp.innerHTML; + }; + + // Set innerHTML safely - for trusted content only + const setInnerHTML = (element, html) => { + if (!element) return; + + // Use setHTML if available (Sanitizer API) + if (element.setHTML && typeof element.setHTML === 'function') { + element.setHTML(html); + return; + } + + // Fallback: Use template element for safer insertion + const template = document.createElement('template'); + template.innerHTML = html; + + // Clear and append + element.innerHTML = ''; + element.appendChild(template.content.cloneNode(true)); + }; + + // Create element safely using template + const createFromHTML = (html) => { + const template = document.createElement('template'); + template.innerHTML = html; + return template.content.cloneNode(true); + }; + // Efficient DOM element creation with attributes const createElement = (tag, attributes = {}, children = []) => { const element = document.createElement(tag); @@ -127,6 +544,8 @@ const Utils = (() => { Object.assign(element.dataset, value); } else if (key.startsWith('on') && typeof value === 'function') { element.addEventListener(key.slice(2).toLowerCase(), value); + } else if (key === 'innerHTML') { + setInnerHTML(element, value); } else { element.setAttribute(key, value); } @@ -253,10 +672,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 +712,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 @@ -332,6 +754,9 @@ const Utils = (() => { throttle, fetchWithTimeout, fetchWithFallback, + sanitizeHTML, + setInnerHTML, + createFromHTML, createElement, scheduleIdleWork, cancelIdleWork, @@ -341,15 +766,315 @@ 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); + globalEventBus.emit('data-loaded', { type, source: 'cache' }); + return cachedData; + } + + // Fetch from network + try { + const data = await Utils.fetchWithFallback(urls); + + // Cache the data + this.#cache.set(cacheKey, data); + await CacheService.set(cacheKey, data); + + globalEventBus.emit('data-loaded', { type, source: 'network' }); + return data; + } catch (error) { + globalEventBus.emit('data-error', { 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') { + if (e.shiftKey && document.activeElement === first) { + last.focus(); + e.preventDefault(); + } else if (!e.shiftKey && document.activeElement === last) { + first.focus(); + e.preventDefault(); + } + } + + // Escape key to release trap + if (e.key === 'Escape') { + this.release(); + } + }; + + 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(); + rows[currentRow]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + break; + + case 'ArrowUp': + e.preventDefault(); + currentRow = Math.max(currentRow - 1, 0); + rows[currentRow]?.focus(); + rows[currentRow]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + break; + + case 'Home': + if (e.ctrlKey) { + e.preventDefault(); + currentRow = 0; + rows[currentRow]?.focus(); + rows[currentRow]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + break; + + case 'End': + if (e.ctrlKey) { + e.preventDefault(); + currentRow = rows.length - 1; + rows[currentRow]?.focus(); + rows[currentRow]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + break; + } + }; + + // Make rows focusable + const updateRowsFocusability = () => { + getRows().forEach((row, index) => { + row.setAttribute('tabindex', '0'); + row.setAttribute('role', 'row'); + }); + }; + + 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) { + wrapper.classList.add('mobile-view'); + table.setAttribute('data-mobile', 'true'); + } else { + wrapper.classList.remove('mobile-view'); + table.removeAttribute('data-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 +1107,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 +1243,14 @@ class TableManager { #state = null; #renderToken = null; #idleCallbackId = null; + #lifecycle = null; + #searchWorker = null; + #keyboardNavigationCleanup = null; + #responsiveCleanup = null; constructor(containerId, searchInputId, clearIconId, options = {}) { + this.#lifecycle = new ComponentLifecycle(); + this.#elements = { container: document.getElementById(containerId), searchInput: document.getElementById(searchInputId), @@ -523,6 +1263,7 @@ class TableManager { chunkSize: CONFIG.PERFORMANCE.RENDER_CHUNK_SIZE, initialLimit: 30, // Show only first 30 entries initially enableLazyLoad: true, // Enable lazy loading feature + useWebWorker: true, // Enable web worker for search ...options }; @@ -537,11 +1278,37 @@ class TableManager { }); this.#init(); + + // Register cleanup + this.#lifecycle.onDestroy(() => this.#cleanup()); } #init() { if (!this.#elements.container) return; + // Initialize web worker for search operations + if (this.options.useWebWorker && typeof Worker !== 'undefined') { + try { + this.#searchWorker = new Worker('./workers/search.worker.js'); + + // Set up worker message handler once + this.#searchWorker.onmessage = (event) => { + this.#handleSearchWorkerMessage(event); + }; + + this.#searchWorker.onerror = (error) => { + console.error('Search worker error:', error); + }; + + this.#lifecycle.onDestroy(() => { + this.#searchWorker?.terminate(); + }); + } catch (error) { + console.warn('Failed to initialize search worker:', error); + this.#searchWorker = null; + } + } + // Bind event handlers if (this.#elements.searchInput) { const debouncedSearch = Utils.debounce(() => this.#performSearch()); @@ -549,11 +1316,92 @@ class TableManager { this.#elements.searchInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') this.#clearSearch(); }); + + // Register cleanup for debounced function + this.#lifecycle.onDestroy(() => { + debouncedSearch.cancel(); + this.#elements.searchInput.removeEventListener('input', debouncedSearch); + }); } if (this.#elements.clearIcon) { - this.#elements.clearIcon.addEventListener('click', () => this.#clearSearch()); + const clearHandler = () => this.#clearSearch(); + this.#elements.clearIcon.addEventListener('click', clearHandler); + + this.#lifecycle.onDestroy(() => { + this.#elements.clearIcon.removeEventListener('click', clearHandler); + }); } + + // Listen to global events + const unsubDataLoad = globalEventBus.on('data-loaded', (data) => { + console.log('Data loaded event received:', 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(); + } + + #handleSearchWorkerMessage(event) { + const { success, result, error } = event.data; + + if (success) { + const { filtered, isURLSearch, extractedHostname } = result; + const { currentData } = this.#state.get(); + + // Update state + 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); + + // Announce to screen readers + A11yAnnouncer.announce(resultMessage); + + this.#renderTableBody(); + } else { + console.error('Search worker error:', error); + const rawSearchTerm = (this.#elements.searchInput?.value || '').trim(); + const { currentData } = this.#state.get(); + this.#performSearchFallback(rawSearchTerm, currentData); + } + } + + destroy() { + this.#lifecycle.destroy(); } generateTable(rawData) { @@ -597,7 +1445,8 @@ class TableManager { // Create header const thead = Utils.createElement('thead'); - thead.innerHTML = this.#generateHeaderHTML(columns); + const headerHTML = this.#generateHeaderHTML(columns); + Utils.setInnerHTML(thead, headerHTML); table.appendChild(thead); // Create body @@ -614,6 +1463,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(); } @@ -734,16 +1587,19 @@ class TableManager { // Create button const loadAllBtn = Utils.createElement('button', { className: 'btn load-all-btn', - type: 'button' + type: 'button', + 'aria-label': `Load all ${totalEntries} hosts. ${remainingEntries} more to load.` }); - loadAllBtn.innerHTML = ` - + const btnHTML = ` + - Load All ${totalEntries} Hosts (${remainingEntries} more) + Load All ${totalEntries} Hosts (${remainingEntries} more) `; + Utils.setInnerHTML(loadAllBtn, btnHTML); + loadAllBtn.addEventListener('click', () => this.#loadAllEntries(), { once: true }); buttonContainer.appendChild(loadAllBtn); @@ -775,16 +1631,23 @@ class TableManager { const btn = this.#elements.loadAllBtn?.querySelector('.load-all-btn'); if (btn) { btn.disabled = true; - btn.innerHTML = ` -
- Loading... + const loadingHTML = ` + + Loading... `; + Utils.setInnerHTML(btn, loadingHTML); } + // Announce to screen readers + A11yAnnouncer.announce('Loading all hosts. Please wait.'); + // Re-render table body with all entries setTimeout(() => { this.#removeLoadAllButton(); this.#renderTableBody(); + + const { filteredData } = this.#state.get(); + A11yAnnouncer.announce(`All ${Object.keys(filteredData).length} hosts loaded.`); }, 100); } @@ -931,77 +1794,94 @@ class TableManager { 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 - }); - } + // Use web worker if available + if (this.#searchWorker) { + this.#searchWorker.postMessage({ + action: 'search', + data: currentData, + term: rawSearchTerm }); - - // 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) - ) - ); + // Fallback to main thread search + this.#performSearchFallback(rawSearchTerm, currentData); } - - // 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(); } + } + + #performSearchFallback(rawSearchTerm, currentData) { + 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); + + // Announce to screen readers + A11yAnnouncer.announce(resultMessage); this.#renderTableBody(); } @@ -1027,6 +1907,10 @@ class TableManager { } this.#updateSearchResults('', 0, 0); + + // Announce to screen readers + A11yAnnouncer.announce('Search cleared. Showing all services.'); + this.#renderTableBody(); } @@ -1047,13 +1931,14 @@ class TableManager { // Add URL indicator if it's a URL search if (isURLSearch) { - resultsElement.innerHTML = ` + const html = ` ${message} `; + Utils.setInnerHTML(resultsElement, html); } else { resultsElement.textContent = message; } @@ -1110,6 +1995,16 @@ class ComparisonManager { #init() { if (!this.#elements.select1 || !this.#elements.select2) return; + // Clear existing options first (except the first placeholder option) + const clearOptions = (select) => { + while (select.options.length > 1) { + select.remove(1); + } + }; + + clearOptions(this.#elements.select1); + clearOptions(this.#elements.select2); + // Populate select dropdowns with service options this.#services.forEach(service => { const option1 = document.createElement('option'); @@ -1533,10 +2428,20 @@ class PerformanceMonitor { class App { #loadingManager = null; #performanceMonitor = null; + #lifecycle = null; + #tableManagers = []; constructor() { this.#loadingManager = new LoadingManager(); this.#performanceMonitor = new PerformanceMonitor(); + this.#lifecycle = new ComponentLifecycle(); + + // Clean up expired cache on startup + CacheService.cleanExpired().then(count => { + if (count > 0) { + console.log(`✅ Cleaned ${count} expired cache entries`); + } + }); } async init() { @@ -1556,16 +2461,23 @@ class App { 'clear-adult-host-search' ); + this.#tableManagers.push(fileHostsTable, adultHostsTable); + + // Register cleanup + this.#lifecycle.onDestroy(() => { + this.#tableManagers.forEach(table => table.destroy()); + }); + // 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 + // Fetch data using DataService const dataPromises = [ - Utils.fetchWithFallback(CONFIG.API.FILE_HOSTS), - Utils.fetchWithFallback(CONFIG.API.ADULT_HOSTS) + DataService.fetchHosts('file-hosts'), + DataService.fetchHosts('adult-hosts') ]; const results = await Promise.allSettled(dataPromises); @@ -1589,8 +2501,9 @@ class App { const container = document.querySelector(containerId); if (container) { container.innerHTML = ` -
+ `; } @@ -1613,6 +2526,10 @@ class App { this.#loadingManager.hideAll(); } } + + destroy() { + this.#lifecycle.destroy(); + } } /* ============================================================================ diff --git a/dist/workers/search.worker.js b/dist/workers/search.worker.js new file mode 100644 index 0000000..ea01652 --- /dev/null +++ b/dist/workers/search.worker.js @@ -0,0 +1,277 @@ +/** + * Search Worker - Offloads heavy search/filter operations from main thread + * Handles fuzzy matching, hostname extraction, and data filtering + */ + +// Normalize hostname for fuzzy matching +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 +}; + +// Levenshtein distance algorithm +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]; +}; + +// Calculate similarity score between two strings +const calculateSimilarity = (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)); +}; + +// Extract hostname from URL +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; + } +}; + +// Perform search operation +const performSearch = (data, searchTerm) => { + const isURL = extractHostnameFromURL(searchTerm); + const term = isURL || searchTerm.toLowerCase(); + + if (isURL) { + // Use fuzzy matching for URL-based searches + const matches = []; + + for (const [host, hostData] of Object.entries(data)) { + const similarity = calculateSimilarity(host, term); + + // 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 + return { + filtered: Object.fromEntries( + matches.map(({ host, hostData }) => [host, hostData]) + ), + isURLSearch: true, + extractedHostname: term + }; + } else { + // Regular text search (exact substring matching) + return { + filtered: Object.fromEntries( + Object.entries(data).filter(([host]) => + host.toLowerCase().includes(term) + ) + ), + isURLSearch: false + }; + } +}; + +// Filter data based on criteria +const filterData = (data, filters) => { + return Object.fromEntries( + Object.entries(data).filter(([host, hostData]) => { + return Object.entries(filters).every(([key, value]) => { + if (value === null || value === undefined) return true; + return hostData[key] === value; + }); + }) + ); +}; + +// Sort data +const 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); +}; + +// Message handler +self.addEventListener('message', (event) => { + const { action, data, ...params } = event.data; + + try { + let result; + + switch (action) { + case 'search': + result = performSearch(data, params.term); + break; + + case 'filter': + result = filterData(data, params.filters); + break; + + case 'sort': + result = sortData(data, params.column, params.direction); + break; + + case 'calculate-similarity': + result = calculateSimilarity(params.str1, params.str2); + break; + + default: + throw new Error(`Unknown action: ${action}`); + } + + self.postMessage({ + success: true, + action, + result + }); + } catch (error) { + self.postMessage({ + success: false, + action, + error: error.message + }); + } +});