1587 lines
No EOL
48 KiB
JavaScript
1587 lines
No EOL
48 KiB
JavaScript
/**
|
||
* @fileoverview Enhanced Debrid Services Comparison Application
|
||
* Modern, feature-rich JavaScript with performance optimizations and professional UX
|
||
*/
|
||
|
||
// Modern utility functions
|
||
const Utils = {
|
||
// Enhanced debounce with immediate option
|
||
debounce: (func, delay = 300, immediate = false) => {
|
||
let timeout;
|
||
return function executedFunction(...args) {
|
||
const later = () => {
|
||
timeout = null;
|
||
if (!immediate) func.apply(this, args);
|
||
};
|
||
const callNow = immediate && !timeout;
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(later, delay);
|
||
if (callNow) func.apply(this, args);
|
||
};
|
||
},
|
||
|
||
// Throttle function for scroll events
|
||
throttle: (func, limit) => {
|
||
let inThrottle;
|
||
return function() {
|
||
const args = arguments;
|
||
const context = this;
|
||
if (!inThrottle) {
|
||
func.apply(context, args);
|
||
inThrottle = true;
|
||
setTimeout(() => inThrottle = false, limit);
|
||
}
|
||
};
|
||
},
|
||
|
||
// Animate elements with Intersection Observer
|
||
animateOnScroll: (elements, options = {}) => {
|
||
const defaultOptions = {
|
||
threshold: 0.1,
|
||
rootMargin: '0px 0px -50px 0px'
|
||
};
|
||
|
||
const observer = new IntersectionObserver((entries) => {
|
||
entries.forEach(entry => {
|
||
if (entry.isIntersecting) {
|
||
entry.target.classList.add('animate-in');
|
||
observer.unobserve(entry.target);
|
||
}
|
||
});
|
||
}, { ...defaultOptions, ...options });
|
||
|
||
elements.forEach(el => observer.observe(el));
|
||
}
|
||
};
|
||
|
||
// Enhanced loading manager
|
||
class LoadingManager {
|
||
constructor() {
|
||
this.activeLoaders = new Set();
|
||
}
|
||
|
||
show(target, text = 'Loading...') {
|
||
const loaderId = Date.now().toString();
|
||
const loader = document.createElement('div');
|
||
loader.className = 'loading-overlay';
|
||
loader.setAttribute('data-loader-id', loaderId);
|
||
loader.innerHTML = `
|
||
<div class="loading-content">
|
||
<div class="loading-spinner">
|
||
<div class="spinner-ring"></div>
|
||
<div class="spinner-ring"></div>
|
||
<div class="spinner-ring"></div>
|
||
</div>
|
||
<p class="loading-text">${text}</p>
|
||
</div>
|
||
`;
|
||
|
||
if (typeof target === 'string') {
|
||
target = document.querySelector(target);
|
||
}
|
||
|
||
target.style.position = 'relative';
|
||
target.appendChild(loader);
|
||
this.activeLoaders.add(loaderId);
|
||
|
||
requestAnimationFrame(() => {
|
||
loader.classList.add('loading-overlay--visible');
|
||
});
|
||
|
||
return loaderId;
|
||
}
|
||
|
||
hide(target, loaderId) {
|
||
if (typeof target === 'string') {
|
||
target = document.querySelector(target);
|
||
}
|
||
|
||
const loader = target.querySelector(`[data-loader-id="${loaderId}"]`);
|
||
if (loader) {
|
||
loader.classList.add('loading-overlay--hiding');
|
||
this.activeLoaders.delete(loaderId);
|
||
|
||
setTimeout(() => {
|
||
if (loader.parentNode) {
|
||
target.removeChild(loader);
|
||
}
|
||
}, 300);
|
||
}
|
||
}
|
||
|
||
hideAll() {
|
||
this.activeLoaders.forEach(id => {
|
||
const loader = document.querySelector(`[data-loader-id="${id}"]`);
|
||
if (loader) {
|
||
this.hide(loader.parentNode, id);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Enhanced table manager with advanced features
|
||
class TableManager {
|
||
constructor(containerId, searchInputId, clearIconId, options = {}) {
|
||
this.container = document.querySelector(`#${containerId}`);
|
||
this.searchInput = document.querySelector(`#${searchInputId}`);
|
||
this.clearIcon = document.querySelector(`#${clearIconId}`);
|
||
this.options = {
|
||
sortable: true,
|
||
filterable: true,
|
||
pagination: false,
|
||
itemsPerPage: 50,
|
||
...options
|
||
};
|
||
|
||
this.currentData = {};
|
||
this.filteredData = {};
|
||
this.currentSort = { column: null, direction: 'asc' };
|
||
this.currentPage = 1;
|
||
|
||
this._setupSearchFunctionality();
|
||
this._setupKeyboardNavigation();
|
||
}
|
||
|
||
generateTable(data = {}) {
|
||
if (!Object.keys(data).length) {
|
||
this.container.innerHTML = '<div class="empty-state"><p>No data available.</p></div>';
|
||
return;
|
||
}
|
||
|
||
this.currentData = data;
|
||
this.filteredData = { ...data };
|
||
|
||
const providers = Object.keys(data[Object.keys(data)[0]]);
|
||
const tableId = this.container.id.replace('-container', '');
|
||
|
||
// Create table with enhanced features
|
||
this.container.innerHTML = `
|
||
<div class="table-wrapper">
|
||
<table id="${tableId}" class="enhanced-table" aria-label="Service Comparison Table">
|
||
<thead>
|
||
<tr>
|
||
<th class="sortable" data-column="service" tabindex="0" role="columnheader" aria-sort="none">
|
||
<span>Service Name</span>
|
||
<svg class="sort-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M12 5v14M5 12l7-7 7 7"/>
|
||
</svg>
|
||
</th>
|
||
${providers.map(provider => `
|
||
<th class="sortable" data-column="${provider}" tabindex="0" role="columnheader" aria-sort="none">
|
||
<span>${provider}</span>
|
||
<svg class="sort-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M12 5v14M5 12l7-7 7 7"/>
|
||
</svg>
|
||
</th>
|
||
`).join('')}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${this._generateTableRows(this.filteredData, providers)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
${this.options.pagination ? this._createPagination() : ''}
|
||
`;
|
||
|
||
this._setupTableFeatures();
|
||
this._setupAccessibility();
|
||
}
|
||
|
||
_generateTableRows(data, providers) {
|
||
return Object.entries(data).map(([host, providerData]) => `
|
||
<tr data-host="${host.toLowerCase()}" role="row">
|
||
<td class="service-cell" role="gridcell">
|
||
<div class="service-info">
|
||
<span class="service-name">${host}</span>
|
||
</div>
|
||
</td>
|
||
${providers.map(provider => `
|
||
<td class="status-cell" role="gridcell" data-status="${providerData[provider]}">
|
||
<span class="status-indicator ${providerData[provider] === 'yes' ? 'supported' : 'not-supported'}"
|
||
aria-label="${providerData[provider] === 'yes' ? 'Supported' : 'Not supported'}">
|
||
${providerData[provider] === 'yes' ?
|
||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22,4 12,14.01 9,11.01"></polyline></svg>' :
|
||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>'
|
||
}
|
||
</span>
|
||
</td>
|
||
`).join('')}
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
_setupSearchFunctionality() {
|
||
const handleSearch = Utils.debounce(() => {
|
||
const searchTerm = this.searchInput.value.toLowerCase().trim();
|
||
|
||
if (!searchTerm) {
|
||
this.filteredData = { ...this.currentData };
|
||
this.clearIcon.style.display = 'none';
|
||
} else {
|
||
this.filteredData = {};
|
||
Object.entries(this.currentData).forEach(([host, data]) => {
|
||
if (host.toLowerCase().includes(searchTerm)) {
|
||
this.filteredData[host] = data;
|
||
}
|
||
});
|
||
this.clearIcon.style.display = 'block';
|
||
}
|
||
|
||
this._updateTableContent();
|
||
this._updateSearchResults(searchTerm);
|
||
}, 300);
|
||
|
||
this.searchInput?.addEventListener('input', handleSearch);
|
||
this.searchInput?.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
this._clearSearch();
|
||
}
|
||
});
|
||
|
||
this.clearIcon?.addEventListener('click', () => {
|
||
this._clearSearch();
|
||
});
|
||
}
|
||
|
||
_clearSearch() {
|
||
this.searchInput.value = '';
|
||
this.filteredData = { ...this.currentData };
|
||
this.clearIcon.style.display = 'none';
|
||
this._updateTableContent();
|
||
this._updateSearchResults('');
|
||
}
|
||
|
||
_updateSearchResults(searchTerm) {
|
||
const resultCount = Object.keys(this.filteredData).length;
|
||
const totalCount = Object.keys(this.currentData).length;
|
||
|
||
// Update or create search results indicator
|
||
let indicator = this.container.querySelector('.search-results');
|
||
if (!indicator) {
|
||
indicator = document.createElement('div');
|
||
indicator.className = 'search-results';
|
||
this.container.insertBefore(indicator, this.container.querySelector('.table-wrapper'));
|
||
}
|
||
|
||
if (searchTerm) {
|
||
indicator.textContent = `Showing ${resultCount} of ${totalCount} services`;
|
||
indicator.style.display = 'block';
|
||
} else {
|
||
indicator.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
_setupTableFeatures() {
|
||
// Setup sorting
|
||
if (this.options.sortable) {
|
||
this.container.querySelectorAll('th.sortable').forEach(header => {
|
||
header.addEventListener('click', () => this._handleSort(header));
|
||
header.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
this._handleSort(header);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
_handleSort(header) {
|
||
const column = header.getAttribute('data-column');
|
||
const currentDirection = this.currentSort.column === column ? this.currentSort.direction : 'asc';
|
||
const newDirection = currentDirection === 'asc' ? 'desc' : 'asc';
|
||
|
||
this.currentSort = { column, direction: newDirection };
|
||
|
||
// Update header states
|
||
this.container.querySelectorAll('th.sortable').forEach(th => {
|
||
th.setAttribute('aria-sort', 'none');
|
||
th.classList.remove('sorted-asc', 'sorted-desc');
|
||
});
|
||
|
||
header.setAttribute('aria-sort', newDirection === 'asc' ? 'ascending' : 'descending');
|
||
header.classList.add(`sorted-${newDirection}`);
|
||
|
||
this._sortData();
|
||
this._updateTableContent();
|
||
}
|
||
|
||
_sortData() {
|
||
const { column, direction } = this.currentSort;
|
||
const entries = Object.entries(this.filteredData);
|
||
|
||
entries.sort(([hostA, dataA], [hostB, dataB]) => {
|
||
let valueA, valueB;
|
||
|
||
if (column === 'service') {
|
||
valueA = hostA.toLowerCase();
|
||
valueB = hostB.toLowerCase();
|
||
} else {
|
||
// Sort by provider support (yes > no)
|
||
valueA = dataA[column] === 'yes' ? 1 : 0;
|
||
valueB = dataB[column] === 'yes' ? 1 : 0;
|
||
}
|
||
|
||
if (direction === 'asc') {
|
||
return valueA > valueB ? 1 : valueA < valueB ? -1 : 0;
|
||
} else {
|
||
return valueA < valueB ? 1 : valueA > valueB ? -1 : 0;
|
||
}
|
||
});
|
||
|
||
this.filteredData = Object.fromEntries(entries);
|
||
}
|
||
|
||
_updateTableContent() {
|
||
const tbody = this.container.querySelector('tbody');
|
||
const providers = Object.keys(this.currentData[Object.keys(this.currentData)[0]]);
|
||
|
||
if (tbody) {
|
||
tbody.innerHTML = this._generateTableRows(this.filteredData, providers);
|
||
this._setupTableFeatures();
|
||
}
|
||
}
|
||
|
||
_setupKeyboardNavigation() {
|
||
this.container.addEventListener('keydown', (e) => {
|
||
if (e.target.matches('th.sortable') && (e.key === 'Enter' || e.key === ' ')) {
|
||
e.preventDefault();
|
||
this._handleSort(e.target);
|
||
}
|
||
});
|
||
}
|
||
|
||
_setupAccessibility() {
|
||
// Add ARIA labels and descriptions
|
||
const table = this.container.querySelector('table');
|
||
if (table) {
|
||
table.setAttribute('role', 'grid');
|
||
table.setAttribute('aria-label', 'Debrid services comparison table');
|
||
}
|
||
}
|
||
|
||
_createPagination() {
|
||
// Pagination implementation for large datasets
|
||
return `
|
||
<div class="pagination">
|
||
<button class="pagination-btn" data-action="prev" aria-label="Previous page">Previous</button>
|
||
<span class="pagination-info">Page ${this.currentPage}</span>
|
||
<button class="pagination-btn" data-action="next" aria-label="Next page">Next</button>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// Enhanced comparison manager
|
||
class ComparisonManager {
|
||
constructor(containerId, select1Id, select2Id, data) {
|
||
this.container = document.querySelector(`#${containerId}`);
|
||
this.select1 = document.querySelector(`#${select1Id}`);
|
||
this.select2 = document.querySelector(`#${select2Id}`);
|
||
this.data = data;
|
||
this.isComparing = false;
|
||
|
||
this._populateDropdowns();
|
||
this._setupEventListeners();
|
||
this._setupKeyboardShortcuts();
|
||
}
|
||
|
||
_populateDropdowns() {
|
||
const providers = Object.keys(this.data[Object.keys(this.data)[0]]);
|
||
|
||
[this.select1, this.select2].forEach((select, index) => {
|
||
// Add loading state
|
||
select.innerHTML = '<option value="">Loading services...</option>';
|
||
|
||
// Simulate async loading for better UX
|
||
setTimeout(() => {
|
||
select.innerHTML = '<option value="">Choose a service...</option>';
|
||
providers.forEach(provider => {
|
||
const option = document.createElement('option');
|
||
option.value = provider;
|
||
option.textContent = provider;
|
||
select.appendChild(option);
|
||
});
|
||
}, 100 * (index + 1));
|
||
});
|
||
}
|
||
|
||
_generateCompareTable() {
|
||
const provider1 = this.select1.value;
|
||
const provider2 = this.select2.value;
|
||
|
||
if (!provider1 || !provider2) {
|
||
this._showEmptyState();
|
||
return;
|
||
}
|
||
|
||
if (provider1 === provider2) {
|
||
this._showSameProviderWarning();
|
||
return;
|
||
}
|
||
|
||
this.isComparing = true;
|
||
const loaderId = loadingManager.show(this.container, 'Generating comparison...');
|
||
|
||
// Simulate processing time for better UX
|
||
setTimeout(() => {
|
||
this._renderComparisonTable(provider1, provider2);
|
||
loadingManager.hide(this.container, loaderId);
|
||
this._trackComparison(provider1, provider2);
|
||
}, 500);
|
||
}
|
||
|
||
_renderComparisonTable(provider1, provider2) {
|
||
const comparisonData = this._calculateComparisonStats(provider1, provider2);
|
||
|
||
this.container.innerHTML = `
|
||
<div class="comparison-header">
|
||
<h3>Comparing ${provider1} vs ${provider2}</h3>
|
||
<div class="comparison-stats">
|
||
<div class="stat">
|
||
<span class="stat-label">Shared Support</span>
|
||
<span class="stat-value">${comparisonData.shared}</span>
|
||
</div>
|
||
<div class="stat">
|
||
<span class="stat-label">${provider1} Only</span>
|
||
<span class="stat-value">${comparisonData.provider1Only}</span>
|
||
</div>
|
||
<div class="stat">
|
||
<span class="stat-label">${provider2} Only</span>
|
||
<span class="stat-value">${comparisonData.provider2Only}</span>
|
||
</div>
|
||
</div>
|
||
<div class="comparison-actions">
|
||
<button id="close-compare" class="btn btn-secondary">
|
||
<span>Close</span>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M18 6L6 18M6 6l12 12"/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="comparison-filters">
|
||
<label class="filter-option">
|
||
<input type="radio" name="comparison-filter" value="all" checked>
|
||
<span>All Services</span>
|
||
</label>
|
||
<label class="filter-option">
|
||
<input type="radio" name="comparison-filter" value="both">
|
||
<span>Supported by Both</span>
|
||
</label>
|
||
<label class="filter-option">
|
||
<input type="radio" name="comparison-filter" value="different">
|
||
<span>Different Support</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="table-wrapper">
|
||
<table id="compare-table" class="comparison-table" aria-label="Provider Comparison Table">
|
||
<thead>
|
||
<tr>
|
||
<th>Service Name</th>
|
||
<th class="provider-header ${provider1.toLowerCase()}">${provider1}</th>
|
||
<th class="provider-header ${provider2.toLowerCase()}">${provider2}</th>
|
||
<th>Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${this._generateComparisonRows(provider1, provider2)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
|
||
this.container.style.display = 'block';
|
||
this._setupComparisonFeatures();
|
||
|
||
// Animate table in
|
||
requestAnimationFrame(() => {
|
||
this.container.classList.add('comparison-visible');
|
||
});
|
||
}
|
||
|
||
_generateComparisonRows(provider1, provider2) {
|
||
return Object.entries(this.data).map(([host, providerData]) => {
|
||
const support1 = providerData[provider1] === 'yes';
|
||
const support2 = providerData[provider2] === 'yes';
|
||
|
||
let statusClass = '';
|
||
let statusText = '';
|
||
|
||
if (support1 && support2) {
|
||
statusClass = 'both-supported';
|
||
statusText = 'Both';
|
||
} else if (support1) {
|
||
statusClass = 'provider1-only';
|
||
statusText = `${provider1} only`;
|
||
} else if (support2) {
|
||
statusClass = 'provider2-only';
|
||
statusText = `${provider2} only`;
|
||
} else {
|
||
statusClass = 'neither-supported';
|
||
statusText = 'Neither';
|
||
}
|
||
|
||
return `
|
||
<tr class="comparison-row ${statusClass}" data-status="${statusClass}">
|
||
<td class="service-name">${host}</td>
|
||
<td class="support-status ${support1 ? 'supported' : 'not-supported'}">
|
||
<span class="status-indicator" aria-label="${support1 ? 'Supported' : 'Not supported'}">
|
||
${support1 ?
|
||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22,4 12,14.01 9,11.01"></polyline></svg>' :
|
||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>'
|
||
}
|
||
</span>
|
||
</td>
|
||
<td class="support-status ${support2 ? 'supported' : 'not-supported'}">
|
||
<span class="status-indicator" aria-label="${support2 ? 'Supported' : 'Not supported'}">
|
||
${support2 ?
|
||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22,4 12,14.01 9,11.01"></polyline></svg>' :
|
||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>'
|
||
}
|
||
</span>
|
||
</td>
|
||
<td class="status-text">
|
||
<span class="status-badge ${statusClass}">${statusText}</span>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
_calculateComparisonStats(provider1, provider2) {
|
||
let shared = 0;
|
||
let provider1Only = 0;
|
||
let provider2Only = 0;
|
||
|
||
Object.values(this.data).forEach(providerData => {
|
||
const support1 = providerData[provider1] === 'yes';
|
||
const support2 = providerData[provider2] === 'yes';
|
||
|
||
if (support1 && support2) shared++;
|
||
else if (support1) provider1Only++;
|
||
else if (support2) provider2Only++;
|
||
});
|
||
|
||
return { shared, provider1Only, provider2Only };
|
||
}
|
||
|
||
_setupComparisonFeatures() {
|
||
// Close button
|
||
this.container.querySelector('#close-compare')?.addEventListener('click', () => {
|
||
this._closeComparison();
|
||
});
|
||
|
||
// Filter functionality
|
||
this.container.querySelectorAll('input[name="comparison-filter"]').forEach(radio => {
|
||
radio.addEventListener('change', (e) => {
|
||
this._filterComparison(e.target.value);
|
||
});
|
||
});
|
||
}
|
||
|
||
_filterComparison(filter) {
|
||
const rows = this.container.querySelectorAll('.comparison-row');
|
||
|
||
rows.forEach(row => {
|
||
const status = row.getAttribute('data-status');
|
||
let show = true;
|
||
|
||
switch (filter) {
|
||
case 'both':
|
||
show = status === 'both-supported';
|
||
break;
|
||
case 'different':
|
||
show = status === 'provider1-only' || status === 'provider2-only';
|
||
break;
|
||
case 'all':
|
||
default:
|
||
show = true;
|
||
}
|
||
|
||
row.style.display = show ? '' : 'none';
|
||
});
|
||
|
||
// Update visible count
|
||
const visibleRows = this.container.querySelectorAll('.comparison-row:not([style*="display: none"])').length;
|
||
let indicator = this.container.querySelector('.filter-results');
|
||
|
||
if (!indicator) {
|
||
indicator = document.createElement('div');
|
||
indicator.className = 'filter-results';
|
||
this.container.querySelector('.table-wrapper').insertAdjacentElement('beforebegin', indicator);
|
||
}
|
||
|
||
indicator.textContent = `Showing ${visibleRows} services`;
|
||
}
|
||
|
||
_closeComparison() {
|
||
this.container.classList.add('comparison-hiding');
|
||
|
||
setTimeout(() => {
|
||
this.container.style.display = 'none';
|
||
this.container.classList.remove('comparison-visible', 'comparison-hiding');
|
||
this.isComparing = false;
|
||
this._showEmptyState();
|
||
}, 300);
|
||
}
|
||
|
||
_showEmptyState() {
|
||
this.container.innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="empty-state-icon">⚖️</div>
|
||
<h3>Ready to Compare</h3>
|
||
<p>Select two services above to see a detailed comparison</p>
|
||
</div>
|
||
`;
|
||
this.container.style.display = 'block';
|
||
}
|
||
|
||
_showSameProviderWarning() {
|
||
this.container.innerHTML = `
|
||
<div class="warning-state">
|
||
<div class="warning-icon">⚠️</div>
|
||
<h3>Same Service Selected</h3>
|
||
<p>Please select two different services to compare</p>
|
||
</div>
|
||
`;
|
||
this.container.style.display = 'block';
|
||
}
|
||
|
||
_setupEventListeners() {
|
||
[this.select1, this.select2].forEach(select => {
|
||
select?.addEventListener('change', () => {
|
||
this._generateCompareTable();
|
||
});
|
||
});
|
||
}
|
||
|
||
_setupKeyboardShortcuts() {
|
||
document.addEventListener('keydown', (e) => {
|
||
if (this.isComparing && e.key === 'Escape') {
|
||
this._closeComparison();
|
||
}
|
||
});
|
||
}
|
||
|
||
_trackComparison(provider1, provider2) {
|
||
// Analytics tracking for popular comparisons
|
||
if (typeof gtag !== 'undefined') {
|
||
gtag('event', 'comparison_generated', {
|
||
provider_1: provider1,
|
||
provider_2: provider2
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Enhanced pricing manager
|
||
class PricingManager {
|
||
constructor(containerId) {
|
||
this.container = document.querySelector(`#${containerId}-table-container`);
|
||
this.currentData = null;
|
||
}
|
||
|
||
generatePricingTable(data = {}) {
|
||
if (!data.plans?.length) {
|
||
this.container.innerHTML = '<div class="error-state"><p>Error: Invalid pricing data structure.</p></div>';
|
||
return;
|
||
}
|
||
|
||
this.currentData = data;
|
||
const services = Object.keys(data.plans[0]).filter(key => key !== 'name');
|
||
|
||
this.container.innerHTML = `
|
||
<div class="table-wrapper">
|
||
<table id="pricing-table" class="pricing-table" aria-label="Service Pricing Table">
|
||
<thead>
|
||
<tr>
|
||
<th>Plans</th>
|
||
${services.map(service => `
|
||
<th class="service-column">
|
||
<div class="service-header">
|
||
<span class="service-name">${service}</span>
|
||
</div>
|
||
</th>
|
||
`).join('')}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${data.plans.map((plan, index) => `
|
||
<tr class="pricing-row ${index % 2 === 0 ? 'even' : 'odd'}">
|
||
<td class="plan-name">
|
||
<strong>${plan.name}</strong>
|
||
</td>
|
||
${services.map(service => {
|
||
const price = plan[service];
|
||
const isNumeric = !isNaN(parseFloat(price));
|
||
|
||
return `
|
||
<td class="price-cell ${isNumeric ? 'numeric-price' : 'text-price'}">
|
||
<div class="price-content">
|
||
<span class="price-value">${price}</span>
|
||
${isNumeric ? '<span class="price-period">/month</span>' : ''}
|
||
</div>
|
||
</td>
|
||
`;
|
||
}).join('')}
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
|
||
this._setupPricingFeatures();
|
||
}
|
||
|
||
_setupPricingFeatures() {
|
||
// Add hover effects for better UX
|
||
const priceCells = this.container.querySelectorAll('.price-cell');
|
||
priceCells.forEach(cell => {
|
||
cell.addEventListener('mouseenter', () => {
|
||
const column = Array.from(cell.parentNode.children).indexOf(cell);
|
||
this._highlightColumn(column);
|
||
});
|
||
|
||
cell.addEventListener('mouseleave', () => {
|
||
this._removeColumnHighlight();
|
||
});
|
||
});
|
||
}
|
||
|
||
_highlightColumn(columnIndex) {
|
||
const table = this.container.querySelector('table');
|
||
const cells = table.querySelectorAll(`td:nth-child(${columnIndex + 1}), th:nth-child(${columnIndex + 1})`);
|
||
cells.forEach(cell => cell.classList.add('highlighted'));
|
||
}
|
||
|
||
_removeColumnHighlight() {
|
||
const highlightedCells = this.container.querySelectorAll('.highlighted');
|
||
highlightedCells.forEach(cell => cell.classList.remove('highlighted'));
|
||
}
|
||
}
|
||
|
||
// Performance monitor
|
||
class PerformanceMonitor {
|
||
constructor() {
|
||
this.metrics = {
|
||
loadStart: performance.now(),
|
||
domReady: null,
|
||
fullyLoaded: null
|
||
};
|
||
}
|
||
|
||
markDOMReady() {
|
||
this.metrics.domReady = performance.now();
|
||
console.log(`DOM Ready: ${(this.metrics.domReady - this.metrics.loadStart).toFixed(2)}ms`);
|
||
}
|
||
|
||
markFullyLoaded() {
|
||
this.metrics.fullyLoaded = performance.now();
|
||
console.log(`Fully Loaded: ${(this.metrics.fullyLoaded - this.metrics.loadStart).toFixed(2)}ms`);
|
||
}
|
||
|
||
getMetrics() {
|
||
return {
|
||
...this.metrics,
|
||
totalLoadTime: this.metrics.fullyLoaded - this.metrics.loadStart,
|
||
domLoadTime: this.metrics.domReady - this.metrics.loadStart
|
||
};
|
||
}
|
||
}
|
||
|
||
// Global instances
|
||
const loadingManager = new LoadingManager();
|
||
const performanceMonitor = new PerformanceMonitor();
|
||
|
||
// Enhanced application initialization
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
performanceMonitor.markDOMReady();
|
||
|
||
try {
|
||
// Initialize managers with enhanced options
|
||
const fileHostsTable = new TableManager(
|
||
'file-hosts-table-container',
|
||
'host-search-input',
|
||
'clear-host-search',
|
||
{ sortable: true }
|
||
);
|
||
|
||
const adultHostsTable = new TableManager(
|
||
'adult-hosts-table-container',
|
||
'adult-host-search-input',
|
||
'clear-adult-host-search',
|
||
{ sortable: true }
|
||
);
|
||
|
||
const pricingManager = new PricingManager('pricing');
|
||
|
||
// Show loading states
|
||
const loaderId1 = loadingManager.show('#file-hosts-table-container', 'Loading file hosts...');
|
||
const loaderId2 = loadingManager.show('#adult-hosts-table-container', 'Loading adult hosts...');
|
||
const loaderId3 = loadingManager.show('#pricing-table-container', 'Loading pricing data...');
|
||
|
||
// Enhanced data loading with retry logic
|
||
const loadDataWithRetry = async (url, retries = 3) => {
|
||
for (let i = 0; i < retries; i++) {
|
||
try {
|
||
const response = await fetch(url);
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
return await response.json();
|
||
} catch (error) {
|
||
if (i === retries - 1) throw error;
|
||
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
|
||
}
|
||
}
|
||
};
|
||
|
||
// Load data with enhanced error handling
|
||
const [fileHostsData, adultHostsData, pricingData] = await Promise.allSettled([
|
||
loadDataWithRetry('./json/file-hosts.json'),
|
||
loadDataWithRetry('./json/adult-hosts.json'),
|
||
loadDataWithRetry('./json/pricing.json')
|
||
]);
|
||
|
||
// Process results
|
||
if (fileHostsData.status === 'fulfilled') {
|
||
loadingManager.hide('#file-hosts-table-container', loaderId1);
|
||
fileHostsTable.generateTable(fileHostsData.value);
|
||
new ComparisonManager('compare-table-container', 'provider1', 'provider2', fileHostsData.value);
|
||
} else {
|
||
loadingManager.hide('#file-hosts-table-container', loaderId1);
|
||
console.error('File hosts error:', fileHostsData.reason);
|
||
}
|
||
|
||
if (adultHostsData.status === 'fulfilled') {
|
||
loadingManager.hide('#adult-hosts-table-container', loaderId2);
|
||
adultHostsTable.generateTable(adultHostsData.value);
|
||
} else {
|
||
loadingManager.hide('#adult-hosts-table-container', loaderId2);
|
||
console.error('Adult hosts error:', adultHostsData.reason);
|
||
}
|
||
|
||
if (pricingData.status === 'fulfilled') {
|
||
loadingManager.hide('#pricing-table-container', loaderId3);
|
||
pricingManager.generatePricingTable(pricingData.value);
|
||
} else {
|
||
loadingManager.hide('#pricing-table-container', loaderId3);
|
||
console.error('Pricing error:', pricingData.reason);
|
||
}
|
||
|
||
// Setup URL-based comparison
|
||
setupURLComparison();
|
||
|
||
// Setup scroll animations
|
||
setupScrollAnimations();
|
||
|
||
// Setup offline detection
|
||
setupOfflineDetection();
|
||
|
||
performanceMonitor.markFullyLoaded();
|
||
|
||
} catch (error) {
|
||
console.error('Critical error initializing application:', error);
|
||
loadingManager.hideAll();
|
||
}
|
||
});
|
||
|
||
// URL-based comparison feature
|
||
function setupURLComparison() {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const provider1 = urlParams.get('compare');
|
||
const provider2 = urlParams.get('with');
|
||
|
||
if (provider1 && provider2) {
|
||
setTimeout(() => {
|
||
const select1 = document.querySelector('#provider1');
|
||
const select2 = document.querySelector('#provider2');
|
||
|
||
if (select1 && select2) {
|
||
select1.value = provider1;
|
||
select2.value = provider2;
|
||
select1.dispatchEvent(new Event('change'));
|
||
}
|
||
}, 1000);
|
||
}
|
||
}
|
||
|
||
// Scroll animations setup
|
||
function setupScrollAnimations() {
|
||
const animatedElements = document.querySelectorAll('.section, .card, .quick-nav-card');
|
||
Utils.animateOnScroll(animatedElements);
|
||
}
|
||
|
||
// Offline detection
|
||
function setupOfflineDetection() {
|
||
window.addEventListener('online', () => {
|
||
console.log('Connection restored');
|
||
});
|
||
|
||
window.addEventListener('offline', () => {
|
||
console.log('You are offline. Some features may not work.');
|
||
});
|
||
}
|
||
|
||
// Enhanced error boundary
|
||
window.addEventListener('error', (event) => {
|
||
console.error('Global error:', event.error);
|
||
});
|
||
|
||
window.addEventListener('unhandledrejection', (event) => {
|
||
console.error('Unhandled promise rejection:', event.reason);
|
||
event.preventDefault();
|
||
});
|
||
|
||
// ===== MODERN MOBILE-FIRST ENHANCEMENTS =====
|
||
|
||
// Enhanced Touch Gesture Support
|
||
class TouchGestureManager {
|
||
constructor() {
|
||
this.touchStart = null;
|
||
this.touchEnd = null;
|
||
this.minSwipeDistance = 100;
|
||
this.maxSwipeTime = 300;
|
||
this.setupTouchListeners();
|
||
}
|
||
|
||
setupTouchListeners() {
|
||
document.addEventListener('touchstart', (e) => {
|
||
this.touchStart = {
|
||
x: e.changedTouches[0].screenX,
|
||
y: e.changedTouches[0].screenY,
|
||
time: Date.now()
|
||
};
|
||
}, { passive: true });
|
||
|
||
document.addEventListener('touchend', (e) => {
|
||
if (!this.touchStart) return;
|
||
|
||
this.touchEnd = {
|
||
x: e.changedTouches[0].screenX,
|
||
y: e.changedTouches[0].screenY,
|
||
time: Date.now()
|
||
};
|
||
|
||
this.handleSwipe();
|
||
}, { passive: true });
|
||
}
|
||
|
||
handleSwipe() {
|
||
if (!this.touchStart || !this.touchEnd) return;
|
||
|
||
const deltaX = this.touchEnd.x - this.touchStart.x;
|
||
const deltaY = this.touchEnd.y - this.touchStart.y;
|
||
const deltaTime = this.touchEnd.time - this.touchStart.time;
|
||
|
||
if (deltaTime > this.maxSwipeTime) return;
|
||
|
||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||
if (distance < this.minSwipeDistance) return;
|
||
|
||
// Determine swipe direction
|
||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||
// Horizontal swipe
|
||
if (deltaX > 0) {
|
||
this.dispatchSwipeEvent('swiperight');
|
||
} else {
|
||
this.dispatchSwipeEvent('swipeleft');
|
||
}
|
||
} else {
|
||
// Vertical swipe
|
||
if (deltaY > 0) {
|
||
this.dispatchSwipeEvent('swipedown');
|
||
} else {
|
||
this.dispatchSwipeEvent('swipeup');
|
||
}
|
||
}
|
||
}
|
||
|
||
dispatchSwipeEvent(type) {
|
||
document.dispatchEvent(new CustomEvent(type, {
|
||
detail: {
|
||
startPos: this.touchStart,
|
||
endPos: this.touchEnd
|
||
}
|
||
}));
|
||
}
|
||
}
|
||
|
||
// Virtual Scrolling for Large Datasets
|
||
class VirtualScrollManager {
|
||
constructor(container, itemHeight = 50) {
|
||
this.container = container;
|
||
this.itemHeight = itemHeight;
|
||
this.viewportHeight = container.clientHeight;
|
||
this.totalItems = 0;
|
||
this.visibleStart = 0;
|
||
this.visibleEnd = 0;
|
||
this.scrollContent = null;
|
||
this.renderCallback = null;
|
||
}
|
||
|
||
init(items, renderCallback) {
|
||
this.totalItems = items.length;
|
||
this.renderCallback = renderCallback;
|
||
this.setupVirtualScroll();
|
||
this.updateVisibleRange();
|
||
}
|
||
|
||
setupVirtualScroll() {
|
||
this.container.innerHTML = `
|
||
<div class="virtual-scroll-content" style="height: ${this.totalItems * this.itemHeight}px;">
|
||
<div class="virtual-scroll-viewport"></div>
|
||
</div>
|
||
`;
|
||
|
||
this.scrollContent = this.container.querySelector('.virtual-scroll-viewport');
|
||
|
||
this.container.addEventListener('scroll', Utils.throttle(() => {
|
||
this.updateVisibleRange();
|
||
}, 16)); // 60fps
|
||
}
|
||
|
||
updateVisibleRange() {
|
||
const scrollTop = this.container.scrollTop;
|
||
const buffer = Math.ceil(this.viewportHeight / this.itemHeight);
|
||
|
||
this.visibleStart = Math.max(0, Math.floor(scrollTop / this.itemHeight) - buffer);
|
||
this.visibleEnd = Math.min(this.totalItems, this.visibleStart + buffer * 3);
|
||
|
||
this.renderVisibleItems();
|
||
}
|
||
|
||
renderVisibleItems() {
|
||
if (!this.renderCallback) return;
|
||
|
||
const fragment = document.createDocumentFragment();
|
||
|
||
for (let i = this.visibleStart; i < this.visibleEnd; i++) {
|
||
const item = this.renderCallback(i);
|
||
item.style.position = 'absolute';
|
||
item.style.top = `${i * this.itemHeight}px`;
|
||
item.style.height = `${this.itemHeight}px`;
|
||
item.style.width = '100%';
|
||
fragment.appendChild(item);
|
||
}
|
||
|
||
this.scrollContent.innerHTML = '';
|
||
this.scrollContent.appendChild(fragment);
|
||
}
|
||
}
|
||
|
||
// Enhanced Search with Debouncing and Suggestions
|
||
class SearchManager {
|
||
constructor(input, suggestionsContainer) {
|
||
this.input = input;
|
||
this.suggestionsContainer = suggestionsContainer;
|
||
this.currentIndex = -1;
|
||
this.suggestions = [];
|
||
this.setupEventListeners();
|
||
}
|
||
|
||
setupEventListeners() {
|
||
this.input.addEventListener('input', Utils.debounce((e) => {
|
||
this.handleInput(e.target.value);
|
||
}, 300));
|
||
|
||
this.input.addEventListener('keydown', (e) => {
|
||
this.handleKeyDown(e);
|
||
});
|
||
|
||
this.input.addEventListener('blur', () => {
|
||
setTimeout(() => this.hideSuggestions(), 200);
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
if (!this.input.contains(e.target) && !this.suggestionsContainer.contains(e.target)) {
|
||
this.hideSuggestions();
|
||
}
|
||
});
|
||
}
|
||
|
||
async handleInput(query) {
|
||
if (query.length < 2) {
|
||
this.hideSuggestions();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
this.suggestions = await this.fetchSuggestions(query);
|
||
this.renderSuggestions();
|
||
} catch (error) {
|
||
console.error('Error fetching suggestions:', error);
|
||
}
|
||
}
|
||
|
||
async fetchSuggestions(query) {
|
||
// In a real app, this would fetch from an API
|
||
// For now, we'll filter from existing data
|
||
const allHosts = Object.keys(window.fileHostsData || {});
|
||
return allHosts
|
||
.filter(host => host.toLowerCase().includes(query.toLowerCase()))
|
||
.slice(0, 5);
|
||
}
|
||
|
||
renderSuggestions() {
|
||
if (this.suggestions.length === 0) {
|
||
this.hideSuggestions();
|
||
return;
|
||
}
|
||
|
||
this.suggestionsContainer.innerHTML = this.suggestions
|
||
.map((suggestion, index) => `
|
||
<div class="search-suggestion" data-index="${index}">
|
||
${this.highlightMatch(suggestion, this.input.value)}
|
||
</div>
|
||
`)
|
||
.join('');
|
||
|
||
this.suggestionsContainer.classList.add('visible');
|
||
this.currentIndex = -1;
|
||
|
||
// Add click listeners
|
||
this.suggestionsContainer.querySelectorAll('.search-suggestion').forEach((el, index) => {
|
||
el.addEventListener('click', () => {
|
||
this.selectSuggestion(index);
|
||
});
|
||
});
|
||
}
|
||
|
||
highlightMatch(text, query) {
|
||
const regex = new RegExp(`(${query})`, 'gi');
|
||
return text.replace(regex, '<strong>$1</strong>');
|
||
}
|
||
|
||
handleKeyDown(e) {
|
||
if (!this.suggestionsContainer.classList.contains('visible')) return;
|
||
|
||
switch (e.key) {
|
||
case 'ArrowDown':
|
||
e.preventDefault();
|
||
this.navigateSuggestions(1);
|
||
break;
|
||
case 'ArrowUp':
|
||
e.preventDefault();
|
||
this.navigateSuggestions(-1);
|
||
break;
|
||
case 'Enter':
|
||
e.preventDefault();
|
||
if (this.currentIndex >= 0) {
|
||
this.selectSuggestion(this.currentIndex);
|
||
}
|
||
break;
|
||
case 'Escape':
|
||
this.hideSuggestions();
|
||
break;
|
||
}
|
||
}
|
||
|
||
navigateSuggestions(direction) {
|
||
const suggestions = this.suggestionsContainer.querySelectorAll('.search-suggestion');
|
||
|
||
// Remove current highlight
|
||
if (this.currentIndex >= 0) {
|
||
suggestions[this.currentIndex].classList.remove('highlighted');
|
||
}
|
||
|
||
// Update index
|
||
this.currentIndex += direction;
|
||
if (this.currentIndex < 0) this.currentIndex = suggestions.length - 1;
|
||
if (this.currentIndex >= suggestions.length) this.currentIndex = 0;
|
||
|
||
// Add new highlight
|
||
suggestions[this.currentIndex].classList.add('highlighted');
|
||
suggestions[this.currentIndex].scrollIntoView({ block: 'nearest' });
|
||
}
|
||
|
||
selectSuggestion(index) {
|
||
this.input.value = this.suggestions[index];
|
||
this.hideSuggestions();
|
||
this.input.dispatchEvent(new Event('input'));
|
||
}
|
||
|
||
hideSuggestions() {
|
||
this.suggestionsContainer.classList.remove('visible');
|
||
this.currentIndex = -1;
|
||
}
|
||
}
|
||
|
||
// Pull-to-Refresh for Mobile
|
||
class PullToRefreshManager {
|
||
constructor(container, callback) {
|
||
this.container = container;
|
||
this.callback = callback;
|
||
this.isRefreshing = false;
|
||
this.startY = 0;
|
||
this.currentY = 0;
|
||
this.threshold = 80;
|
||
this.setupPullToRefresh();
|
||
}
|
||
|
||
setupPullToRefresh() {
|
||
this.container.classList.add('pull-to-refresh');
|
||
|
||
const indicator = document.createElement('div');
|
||
indicator.className = 'pull-to-refresh-indicator';
|
||
indicator.innerHTML = '<div class="loading-spinner"></div>';
|
||
this.container.appendChild(indicator);
|
||
|
||
this.container.addEventListener('touchstart', (e) => {
|
||
if (this.container.scrollTop === 0 && !this.isRefreshing) {
|
||
this.startY = e.touches[0].clientY;
|
||
}
|
||
}, { passive: true });
|
||
|
||
this.container.addEventListener('touchmove', (e) => {
|
||
if (this.startY && !this.isRefreshing) {
|
||
this.currentY = e.touches[0].clientY;
|
||
const pullDistance = this.currentY - this.startY;
|
||
|
||
if (pullDistance > 0 && this.container.scrollTop === 0) {
|
||
e.preventDefault();
|
||
const pullRatio = Math.min(pullDistance / this.threshold, 1);
|
||
|
||
if (pullDistance > this.threshold) {
|
||
this.container.classList.add('pulling');
|
||
}
|
||
}
|
||
}
|
||
}, { passive: false });
|
||
|
||
this.container.addEventListener('touchend', () => {
|
||
if (this.startY && this.currentY && !this.isRefreshing) {
|
||
const pullDistance = this.currentY - this.startY;
|
||
|
||
if (pullDistance > this.threshold) {
|
||
this.triggerRefresh();
|
||
}
|
||
}
|
||
|
||
this.container.classList.remove('pulling');
|
||
this.startY = 0;
|
||
this.currentY = 0;
|
||
}, { passive: true });
|
||
}
|
||
|
||
async triggerRefresh() {
|
||
if (this.isRefreshing) return;
|
||
|
||
this.isRefreshing = true;
|
||
|
||
try {
|
||
await this.callback();
|
||
} catch (error) {
|
||
console.error('Refresh failed:', error);
|
||
} finally {
|
||
this.isRefreshing = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Toast Notification System
|
||
class ToastManager {
|
||
constructor() {
|
||
this.container = this.createContainer();
|
||
this.toasts = new Map();
|
||
}
|
||
|
||
createContainer() {
|
||
let container = document.querySelector('.toast-container');
|
||
if (!container) {
|
||
container = document.createElement('div');
|
||
container.className = 'toast-container';
|
||
document.body.appendChild(container);
|
||
}
|
||
return container;
|
||
}
|
||
|
||
show(message, type = 'info', duration = 3000) {
|
||
const id = Date.now().toString();
|
||
const toast = this.createToast(message, type, id);
|
||
|
||
this.container.appendChild(toast);
|
||
this.toasts.set(id, toast);
|
||
|
||
// Auto-remove after duration
|
||
if (duration > 0) {
|
||
setTimeout(() => {
|
||
this.remove(id);
|
||
}, duration);
|
||
}
|
||
|
||
return id;
|
||
}
|
||
|
||
createToast(message, type, id) {
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
toast.setAttribute('data-toast-id', id);
|
||
|
||
toast.innerHTML = `
|
||
<div class="toast-content">
|
||
<span class="toast-message">${message}</span>
|
||
<button class="toast-close" aria-label="Close notification">×</button>
|
||
</div>
|
||
`;
|
||
|
||
const closeBtn = toast.querySelector('.toast-close');
|
||
closeBtn.addEventListener('click', () => {
|
||
this.remove(id);
|
||
});
|
||
|
||
return toast;
|
||
}
|
||
|
||
remove(id) {
|
||
const toast = this.toasts.get(id);
|
||
if (toast) {
|
||
toast.style.animation = 'slideOutRight 0.3s ease forwards';
|
||
setTimeout(() => {
|
||
if (toast.parentNode) {
|
||
this.container.removeChild(toast);
|
||
}
|
||
this.toasts.delete(id);
|
||
}, 300);
|
||
}
|
||
}
|
||
|
||
success(message, duration = 3000) {
|
||
return this.show(message, 'success', duration);
|
||
}
|
||
|
||
error(message, duration = 5000) {
|
||
return this.show(message, 'error', duration);
|
||
}
|
||
|
||
warning(message, duration = 4000) {
|
||
return this.show(message, 'warning', duration);
|
||
}
|
||
|
||
info(message, duration = 3000) {
|
||
return this.show(message, 'info', duration);
|
||
}
|
||
}
|
||
|
||
// Mobile Navigation Manager
|
||
class MobileNavManager {
|
||
constructor() {
|
||
this.setupMobileNav();
|
||
}
|
||
|
||
setupMobileNav() {
|
||
if (window.innerWidth <= 768) {
|
||
this.createMobileNav();
|
||
}
|
||
|
||
window.addEventListener('resize', Utils.debounce(() => {
|
||
if (window.innerWidth <= 768) {
|
||
this.createMobileNav();
|
||
} else {
|
||
this.removeMobileNav();
|
||
}
|
||
}, 300));
|
||
}
|
||
|
||
createMobileNav() {
|
||
if (document.querySelector('.mobile-nav-toggle')) return;
|
||
|
||
const toggle = document.createElement('button');
|
||
toggle.className = 'mobile-nav-toggle';
|
||
toggle.innerHTML = '☰';
|
||
toggle.setAttribute('aria-label', 'Toggle mobile navigation');
|
||
|
||
const menu = document.createElement('div');
|
||
menu.className = 'mobile-nav-menu';
|
||
menu.innerHTML = `
|
||
<a href="#pricing" class="mobile-nav-item">Pricing</a>
|
||
<a href="#file-hosts" class="mobile-nav-item">File Hosts</a>
|
||
<a href="#compare" class="mobile-nav-item">Compare</a>
|
||
<a href="#adult-hosts" class="mobile-nav-item">Adult Hosts</a>
|
||
<a href="#live-status" class="mobile-nav-item">Status</a>
|
||
`;
|
||
|
||
document.body.appendChild(toggle);
|
||
document.body.appendChild(menu);
|
||
|
||
toggle.addEventListener('click', () => {
|
||
menu.classList.toggle('visible');
|
||
});
|
||
|
||
// Close menu when clicking outside
|
||
document.addEventListener('click', (e) => {
|
||
if (!toggle.contains(e.target) && !menu.contains(e.target)) {
|
||
menu.classList.remove('visible');
|
||
}
|
||
});
|
||
|
||
// Close menu when clicking on a link
|
||
menu.querySelectorAll('.mobile-nav-item').forEach(link => {
|
||
link.addEventListener('click', () => {
|
||
menu.classList.remove('visible');
|
||
});
|
||
});
|
||
}
|
||
|
||
removeMobileNav() {
|
||
const toggle = document.querySelector('.mobile-nav-toggle');
|
||
const menu = document.querySelector('.mobile-nav-menu');
|
||
|
||
if (toggle) toggle.remove();
|
||
if (menu) menu.remove();
|
||
}
|
||
}
|
||
|
||
// Progressive Enhancement Manager
|
||
class ProgressiveEnhancementManager {
|
||
constructor() {
|
||
this.features = new Map();
|
||
this.detectFeatures();
|
||
}
|
||
|
||
detectFeatures() {
|
||
// Detect various browser capabilities
|
||
this.features.set('intersectionObserver', 'IntersectionObserver' in window);
|
||
this.features.set('webp', this.supportsWebP());
|
||
this.features.set('serviceWorker', 'serviceWorker' in navigator);
|
||
this.features.set('touchEvents', 'ontouchstart' in window);
|
||
this.features.set('pointerEvents', 'PointerEvent' in window);
|
||
this.features.set('containerQueries', CSS.supports('container-type: inline-size'));
|
||
}
|
||
|
||
async supportsWebP() {
|
||
return new Promise((resolve) => {
|
||
const webP = new Image();
|
||
webP.onload = webP.onerror = () => {
|
||
resolve(webP.height === 2);
|
||
};
|
||
webP.src = 'data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA';
|
||
});
|
||
}
|
||
|
||
hasFeature(feature) {
|
||
return this.features.get(feature) || false;
|
||
}
|
||
|
||
enhanceIfSupported(feature, enhancementCallback) {
|
||
if (this.hasFeature(feature)) {
|
||
enhancementCallback();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Initialize modern mobile-first features
|
||
const touchGestureManager = new TouchGestureManager();
|
||
const toastManager = new ToastManager();
|
||
const mobileNavManager = new MobileNavManager();
|
||
const progressiveEnhancementManager = new ProgressiveEnhancementManager();
|
||
|
||
// Initialize search enhancements
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// Enhanced search for file hosts
|
||
const fileHostSearch = document.querySelector('#file-host-search');
|
||
const fileHostSuggestions = document.createElement('div');
|
||
fileHostSuggestions.className = 'search-suggestions';
|
||
|
||
if (fileHostSearch) {
|
||
fileHostSearch.parentNode.classList.add('search-container');
|
||
fileHostSearch.parentNode.appendChild(fileHostSuggestions);
|
||
new SearchManager(fileHostSearch, fileHostSuggestions);
|
||
}
|
||
|
||
// Enhanced search for adult hosts
|
||
const adultHostSearch = document.querySelector('#adult-host-search');
|
||
const adultHostSuggestions = document.createElement('div');
|
||
adultHostSuggestions.className = 'search-suggestions';
|
||
|
||
if (adultHostSearch) {
|
||
adultHostSearch.parentNode.classList.add('search-container');
|
||
adultHostSearch.parentNode.appendChild(adultHostSuggestions);
|
||
new SearchManager(adultHostSearch, adultHostSuggestions);
|
||
}
|
||
|
||
// Setup pull-to-refresh for mobile
|
||
if (progressiveEnhancementManager.hasFeature('touchEvents')) {
|
||
const mainContent = document.querySelector('main');
|
||
if (mainContent) {
|
||
new PullToRefreshManager(mainContent, async () => {
|
||
toastManager.info('Refreshing data...');
|
||
// Simulate refresh
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
window.location.reload();
|
||
});
|
||
}
|
||
}
|
||
|
||
// Add scroll progress indicator
|
||
const scrollIndicator = document.createElement('div');
|
||
scrollIndicator.className = 'scroll-indicator';
|
||
scrollIndicator.innerHTML = '<div class="scroll-indicator-bar"></div>';
|
||
document.body.appendChild(scrollIndicator);
|
||
|
||
const updateScrollProgress = () => {
|
||
const winScroll = document.body.scrollTop || document.documentElement.scrollTop;
|
||
const height = document.documentElement.scrollHeight - document.documentElement.clientHeight;
|
||
const scrolled = (winScroll / height) * 100;
|
||
|
||
const bar = scrollIndicator.querySelector('.scroll-indicator-bar');
|
||
bar.style.width = scrolled + '%';
|
||
};
|
||
|
||
window.addEventListener('scroll', Utils.throttle(updateScrollProgress, 16));
|
||
});
|
||
|
||
// Enhanced swipe gesture handling
|
||
document.addEventListener('swipeleft', (e) => {
|
||
// Handle left swipe - could navigate to next section
|
||
console.log('Swiped left');
|
||
});
|
||
|
||
document.addEventListener('swiperight', (e) => {
|
||
// Handle right swipe - could navigate to previous section
|
||
console.log('Swiped right');
|
||
});
|
||
|
||
// Service Worker registration enhancement
|
||
if (progressiveEnhancementManager.hasFeature('serviceWorker')) {
|
||
navigator.serviceWorker.register('/sw.js')
|
||
.then((registration) => {
|
||
console.log('ServiceWorker registration successful');
|
||
|
||
// Check for updates
|
||
registration.addEventListener('updatefound', () => {
|
||
const newWorker = registration.installing;
|
||
newWorker.addEventListener('statechange', () => {
|
||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||
toastManager.info('New version available! Refresh to update.', 0);
|
||
}
|
||
});
|
||
});
|
||
})
|
||
.catch((error) => {
|
||
console.log('ServiceWorker registration failed:', error);
|
||
});
|
||
}
|
||
|
||
// Performance monitoring
|
||
if ('PerformanceObserver' in window) {
|
||
const perfObserver = new PerformanceObserver((list) => {
|
||
for (const entry of list.getEntries()) {
|
||
if (entry.entryType === 'largest-contentful-paint') {
|
||
console.log(`LCP: ${entry.startTime}ms`);
|
||
}
|
||
if (entry.entryType === 'first-input') {
|
||
console.log(`FID: ${entry.processingStart - entry.startTime}ms`);
|
||
}
|
||
}
|
||
});
|
||
|
||
try {
|
||
perfObserver.observe({ entryTypes: ['largest-contentful-paint', 'first-input'] });
|
||
} catch (e) {
|
||
console.log('Performance observation not supported');
|
||
}
|
||
} |