feat: implement component lifecycle management and enhance caching mechanisms
This commit is contained in:
parent
ea0fab628f
commit
72cc64f624
1 changed files with 757 additions and 9 deletions
766
dist/js/app.js
vendored
766
dist/js/app.js
vendored
|
|
@ -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<IDBDatabase>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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>} - 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<Object>} - 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();
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue