optimize json

This commit is contained in:
fynks 2025-08-25 10:16:57 +05:00
parent 5a7e179b05
commit be135cb02a
7 changed files with 886 additions and 561 deletions

11
dist/css/styles.css vendored
View file

@ -1409,6 +1409,17 @@ select:focus {
color: var(--text-secondary);
}
.error-state{
padding: 0.5em;
background-color: #ff97e054;
color: #842029;
border-radius: var(--radius-md);
text-align: center;
}
.error-dataurl{
font-family: 'Courier New', Courier, monospace;
text-decoration: underline;
}
.loading-state {
display: flex;
flex-direction: column;

6
dist/index.html vendored
View file

@ -40,7 +40,7 @@
<link rel="prefetch" href="/json/file-hosts.json" crossorigin>
<link rel="prefetch" href="/json/adult-hosts.json" crossorigin>
<link rel="prefetch" href="/json/pricing.json" crossorigin>
<link as="style" rel="preload" href="css/styles.css">
<link as="style" rel="preload" href="./css/styles-min.css">
<script>
(function () {
@ -73,9 +73,9 @@
<meta name="twitter:image" content="https://debrid-services-comparison.netlify.app/image.png">
<link rel="stylesheet" href="css/styles.css">
<link rel="stylesheet" href="./css/styles-min.css">
<script src="js/app.js" defer></script>
<script src="js/app-min.js" defer></script>
</head>

1
dist/js/app-min.js vendored Normal file

File diff suppressed because one or more lines are too long

569
dist/js/app.js vendored
View file

@ -11,69 +11,59 @@
Utils
============================== */
const Utils = (() => {
const debounceCache = new WeakMap();
const debounceMap = new WeakMap();
return {
debounce(func, delay = 300, immediate = false) {
if (debounceCache.has(func)) return debounceCache.get(func);
let timeoutId;
debounce(func, wait = 300, immediate = false) {
if (debounceMap.has(func)) return debounceMap.get(func);
let timeout;
const debounced = function (...args) {
const callNow = immediate && !timeoutId;
clearTimeout(timeoutId);
const callNow = immediate && !timeout;
clearTimeout(timeout);
if (callNow) func.apply(this, args);
timeoutId = setTimeout(() => {
timeoutId = null;
timeout = setTimeout(() => {
timeout = null;
if (!immediate) func.apply(this, args);
}, delay);
}, wait);
};
debounceCache.set(func, debounced);
debounceMap.set(func, debounced);
return debounced;
},
throttle(func, limit = 100) {
let ticking = false;
let inThrottle = false;
return function (...args) {
if (!ticking) {
if (!inThrottle) {
func.apply(this, args);
ticking = true;
inThrottle = true;
requestAnimationFrame(() => {
setTimeout(() => { ticking = false; }, limit);
setTimeout(() => inThrottle = false, limit);
});
}
};
},
animateOnScroll: (() => {
let observer;
return (elements, options = {}) => {
if (!('IntersectionObserver' in window)) return;
if (!observer) {
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in');
entry.target.classList.add("animate-in");
observer.unobserve(entry.target);
}
});
}, {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px',
rootMargin: "0px 0px -50px 0px",
...options
});
}
elements.forEach(el => observer.observe(el));
elements.forEach((element) => observer.observe(element));
};
})()
};
})();
/* ==============================
Loading Manager
============================== */
class LoadingManager {
constructor() {
this.activeLoaders = new Map();
@ -81,7 +71,7 @@ class LoadingManager {
}
createTemplate() {
const template = document.createElement('template');
const template = document.createElement("template");
template.innerHTML = `
<div class="loading-overlay">
<div class="loading-content">
@ -97,42 +87,41 @@ class LoadingManager {
return template;
}
show(target, text = 'Loading...') {
const targetEl = typeof target === 'string' ? document.querySelector(target) : target;
if (!targetEl) return null;
show(target, text = "Loading...") {
const element = typeof target === "string" ? document.querySelector(target) : target;
if (!element) return null;
const loaderId = `loader-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const loader = this.template.content.cloneNode(true).firstElementChild;
const loaderElement = this.template.content.cloneNode(true).firstElementChild;
loaderElement.dataset.loaderId = loaderId;
loaderElement.querySelector(".loading-text").textContent = text;
loader.dataset.loaderId = loaderId;
loader.querySelector('.loading-text').textContent = text;
const computedStyle = getComputedStyle(element);
if (computedStyle.position === "static") {
element.style.position = "relative";
}
const prevPos = getComputedStyle(targetEl).position;
if (prevPos === 'static' || !prevPos) targetEl.style.position = 'relative';
element.appendChild(loaderElement);
this.activeLoaders.set(loaderId, loaderElement);
targetEl.appendChild(loader);
this.activeLoaders.set(loaderId, loader);
requestAnimationFrame(() => loader.classList.add('loading-overlay--visible'));
requestAnimationFrame(() => loaderElement.classList.add("loading-overlay--visible"));
return loaderId;
}
hide(target, loaderId) {
const loader = this.activeLoaders.get(loaderId);
if (!loader) return;
loader.classList.add('loading-overlay--hiding');
const loaderElement = this.activeLoaders.get(loaderId);
if (loaderElement) {
loaderElement.classList.add("loading-overlay--hiding");
this.activeLoaders.delete(loaderId);
setTimeout(() => loader.remove(), 250);
setTimeout(() => loaderElement.remove(), 250);
}
}
hideAll() {
this.activeLoaders.forEach((_, id) => this.hide(null, id));
this.activeLoaders.forEach((_, loaderId) => this.hide(null, loaderId));
}
}
/* ==============================
Table Manager (file/adult hosts)
============================== */
class TableManager {
constructor(containerId, searchInputId, clearIconId, options = {}) {
this.elements = {
@ -141,74 +130,117 @@ class TableManager {
clearIcon: document.getElementById(clearIconId)
};
this.options = { sortable: true, filterable: true, pagination: false, itemsPerPage: 50, ...options };
this.options = {
sortable: true,
filterable: true,
pagination: false,
itemsPerPage: 50,
...options
};
this.state = {
currentData: {},
filteredData: {},
currentSort: { column: null, direction: 'asc' },
currentPage: 1
currentSort: { column: null, direction: "asc" },
currentPage: 1,
services: [], // Store services array for indexed format
isIndexedFormat: false // Flag to track data format
};
this.handleSearch = Utils.debounce(this._performSearch.bind(this), 250);
this.handleSort = this._performSort.bind(this);
this._init();
}
_init() {
if (this.elements.searchInput) {
this.elements.searchInput.addEventListener('input', this.handleSearch);
this.elements.searchInput.addEventListener('keydown', e => {
if (e.key === 'Escape') this._clearSearch();
this.elements.searchInput.addEventListener("input", this.handleSearch);
this.elements.searchInput.addEventListener("keydown", (e) => {
if (e.key === "Escape") this._clearSearch();
});
}
if (this.elements.clearIcon) {
this.elements.clearIcon.addEventListener('click', () => this._clearSearch());
this.elements.clearIcon.addEventListener("click", () => this._clearSearch());
}
}
// New method to transform indexed data to regular format
_transformIndexedData(indexedData) {
const { services, supported } = indexedData;
const transformed = {};
for (const [host, supportedIndices] of Object.entries(supported)) {
transformed[host] = {};
services.forEach((service, index) => {
transformed[host][service] = supportedIndices.includes(index) ? "yes" : "no";
});
}
// Store services for later use
this.state.services = services;
this.state.isIndexedFormat = true;
return transformed;
}
// Modified method to handle both formats
generateTable(data = {}) {
if (!this.elements.container) return;
if (!Object.keys(data).length) {
// Handle both old and new indexed formats
let processedData;
if (data.services && data.supported) {
// New indexed format
processedData = this._transformIndexedData(data);
} else {
// Old format
processedData = data;
this.state.isIndexedFormat = false;
// Try to extract services from first entry
const firstHost = Object.values(processedData)[0];
this.state.services = firstHost ? Object.keys(firstHost) : [];
}
if (!Object.keys(processedData).length) {
this.elements.container.innerHTML = '<div class="empty-state"><p>No data available.</p></div>';
return;
}
this.state.currentData = data;
this.state.filteredData = { ...data };
this.state.currentData = processedData;
this.state.filteredData = { ...processedData };
const firstKey = Object.keys(data)[0];
const providers = firstKey ? Object.keys(data[firstKey]) : [];
const tableId = this.elements.container.id.replace('-container', '');
const firstHostKey = Object.keys(processedData)[0];
const columns = firstHostKey ? Object.keys(processedData[firstHostKey]) : [];
const tableId = this.elements.container.id.replace("-container", "");
const fragment = document.createDocumentFragment();
const wrapper = document.createElement('div');
wrapper.className = 'table-wrapper';
const wrapper = document.createElement("div");
wrapper.className = "table-wrapper";
const table = document.createElement('table');
const table = document.createElement("table");
table.id = tableId;
table.className = 'enhanced-table';
table.setAttribute('aria-label', 'Service Comparison Table');
table.className = "enhanced-table";
table.setAttribute("aria-label", "Service Comparison Table");
const thead = document.createElement('thead');
thead.innerHTML = this._generateTableHeader(providers);
const thead = document.createElement("thead");
thead.innerHTML = this._generateTableHeader(columns);
table.appendChild(thead);
const tbody = document.createElement('tbody');
tbody.innerHTML = this._generateTableRows(this.state.filteredData, providers);
const tbody = document.createElement("tbody");
tbody.innerHTML = this._generateTableRows(this.state.filteredData, columns);
table.appendChild(tbody);
wrapper.appendChild(table);
fragment.appendChild(wrapper);
this.elements.container.innerHTML = '';
this.elements.container.innerHTML = "";
this.elements.container.appendChild(fragment);
this._attachTableEvents();
}
_generateTableHeader(providers) {
_generateTableHeader(columns) {
return `
<tr>
<th class="sortable" data-column="service" tabindex="0" role="columnheader" aria-sort="none">
@ -217,21 +249,21 @@ class TableManager {
<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>
${columns.map(column => `
<th class="sortable" data-column="${column}" tabindex="0" role="columnheader" aria-sort="none">
<span>${column}</span>
<svg class="sort-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 5v14M5 12l7-7 7 7"/>
</svg>
</th>
`).join('')}
`).join("")}
</tr>
`;
}
_generateTableRows(data, providers) {
_generateTableRows(data, columns) {
const rows = [];
for (const [host, providerData] of Object.entries(data)) {
for (const [host, hostData] of Object.entries(data)) {
rows.push(`
<tr data-host="${host.toLowerCase()}" role="row">
<td class="service-cell" role="gridcell">
@ -239,105 +271,118 @@ class TableManager {
<span class="service-name">${host}</span>
</div>
</td>
${providers.map(provider => {
const isSupported = providerData[provider] === 'yes';
${columns.map(column => {
const isSupported = hostData[column] === "yes";
return `
<td class="status-cell" role="gridcell" data-status="${providerData[provider]}">
<span class="status-indicator ${isSupported ? 'supported' : 'not-supported'}" aria-label="${isSupported ? 'Supported' : 'Not supported'}">
${isSupported
? '<svg class="table-check" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><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 class="table-cross" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><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>'
<td class="status-cell" role="gridcell" data-status="${hostData[column]}">
<span class="status-indicator ${isSupported ? "supported" : "not-supported"}" aria-label="${isSupported ? "Supported" : "Not supported"}">
${isSupported ?
'<svg class="table-check" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><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 class="table-cross" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><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('')}
}).join("")}
</tr>
`);
}
return rows.join('');
return rows.join("");
}
_attachTableEvents() {
if (!this.options.sortable) return;
const thead = this.elements.container.querySelector('thead');
const thead = this.elements.container.querySelector("thead");
if (thead) {
thead.addEventListener('click', this.handleSort);
thead.addEventListener('keydown', e => {
const target = e.target.closest('.sortable');
if (!target) return;
if (e.key === 'Enter' || e.key === ' ') {
thead.addEventListener("click", this.handleSort);
thead.addEventListener("keydown", (e) => {
const sortableHeader = e.target.closest(".sortable");
if (sortableHeader && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
this.handleSort({ target });
this.handleSort({ target: sortableHeader });
}
});
}
}
_performSearch() {
const term = (this.elements.searchInput?.value || '').toLowerCase().trim();
const searchTerm = (this.elements.searchInput?.value || "").toLowerCase().trim();
if (!term) {
this.state.filteredData = { ...this.state.currentData };
if (this.elements.clearIcon) this.elements.clearIcon.style.display = 'none';
} else {
if (searchTerm) {
this.state.filteredData = Object.fromEntries(
Object.entries(this.state.currentData).filter(([host]) => host.toLowerCase().includes(term))
Object.entries(this.state.currentData).filter(([host]) =>
host.toLowerCase().includes(searchTerm)
)
);
if (this.elements.clearIcon) this.elements.clearIcon.style.display = 'block';
if (this.elements.clearIcon) {
this.elements.clearIcon.style.display = "block";
}
} else {
this.state.filteredData = { ...this.state.currentData };
if (this.elements.clearIcon) {
this.elements.clearIcon.style.display = "none";
}
}
this._updateTableContent();
this._updateSearchResults(term);
this._updateSearchResults(searchTerm);
}
_clearSearch() {
if (this.elements.searchInput) this.elements.searchInput.value = '';
if (this.elements.searchInput) {
this.elements.searchInput.value = "";
}
this.state.filteredData = { ...this.state.currentData };
if (this.elements.clearIcon) this.elements.clearIcon.style.display = 'none';
if (this.elements.clearIcon) {
this.elements.clearIcon.style.display = "none";
}
this._updateTableContent();
this._updateSearchResults('');
this._updateSearchResults("");
}
_updateSearchResults(searchTerm) {
if (!this.elements.container) return;
let indicator = this.elements.container.querySelector('.search-results');
let resultsElement = this.elements.container.querySelector(".search-results");
if (searchTerm) {
const resultCount = Object.keys(this.state.filteredData).length;
const filteredCount = Object.keys(this.state.filteredData).length;
const totalCount = Object.keys(this.state.currentData).length;
if (!indicator) {
indicator = document.createElement('div');
indicator.className = 'search-results';
this.elements.container.insertBefore(indicator, this.elements.container.firstChild);
if (!resultsElement) {
resultsElement = document.createElement("div");
resultsElement.className = "search-results";
this.elements.container.insertBefore(resultsElement, this.elements.container.firstChild);
}
indicator.textContent = `Showing ${resultCount} of ${totalCount} services`;
indicator.style.display = 'block';
indicator.style.textAlign = 'center';
indicator.style.padding = '0.5em 0';
} else if (indicator) {
indicator.style.display = 'none';
resultsElement.textContent = `Showing ${filteredCount} of ${totalCount} services`;
resultsElement.style.display = "block";
resultsElement.style.textAlign = "center";
resultsElement.style.padding = "0.5em 0";
} else if (resultsElement) {
resultsElement.style.display = "none";
}
}
_performSort(event) {
const header = event.target.closest('.sortable');
if (!header) return;
const sortableHeader = event.target.closest(".sortable");
if (!sortableHeader) return;
const column = header.dataset.column;
const column = sortableHeader.dataset.column;
const { currentSort } = this.state;
const newDirection = currentSort.column === column && currentSort.direction === 'asc' ? 'desc' : 'asc';
const direction = currentSort.column === column && currentSort.direction === "asc" ? "desc" : "asc";
this.state.currentSort = { column, direction: newDirection };
this.state.currentSort = { column, direction };
this.elements.container.querySelectorAll('th.sortable').forEach(th => {
th.setAttribute('aria-sort', 'none');
th.classList.remove('sorted-asc', 'sorted-desc');
// Update UI
this.elements.container.querySelectorAll("th.sortable").forEach(header => {
header.setAttribute("aria-sort", "none");
header.classList.remove("sorted-asc", "sorted-desc");
});
header.setAttribute('aria-sort', newDirection === 'asc' ? 'ascending' : 'descending');
header.classList.add(`sorted-${newDirection}`);
sortableHeader.setAttribute("aria-sort", direction === "asc" ? "ascending" : "descending");
sortableHeader.classList.add(`sorted-${direction}`);
this._sortData();
this._updateTableContent();
@ -348,23 +393,36 @@ class TableManager {
const entries = Object.entries(this.state.filteredData);
entries.sort(([hostA, dataA], [hostB, dataB]) => {
const valueA = column === 'service' ? hostA.toLowerCase() : (dataA[column] === 'yes' ? 1 : 0);
const valueB = column === 'service' ? hostB.toLowerCase() : (dataB[column] === 'yes' ? 1 : 0);
let valueA, valueB;
const comparison = valueA > valueB ? 1 : valueA < valueB ? -1 : 0;
return direction === 'asc' ? comparison : -comparison;
if (column === "service") {
valueA = hostA.toLowerCase();
valueB = hostB.toLowerCase();
} else {
// Convert "yes"/"no" to numeric values for sorting
valueA = dataA[column] === "yes" ? 1 : 0;
valueB = dataB[column] === "yes" ? 1 : 0;
}
let comparison = 0;
if (valueA > valueB) comparison = 1;
else if (valueA < valueB) comparison = -1;
return direction === "asc" ? comparison : -comparison;
});
this.state.filteredData = Object.fromEntries(entries);
}
_updateTableContent() {
const tbody = this.elements.container.querySelector('tbody');
const tbody = this.elements.container.querySelector("tbody");
if (!tbody) return;
const firstKey = Object.keys(this.state.currentData)[0];
const providers = firstKey ? Object.keys(this.state.currentData[firstKey]) : [];
const firstHostKey = Object.keys(this.state.currentData)[0];
const columns = firstHostKey ? Object.keys(this.state.currentData[firstHostKey]) : [];
requestAnimationFrame(() => {
tbody.innerHTML = this._generateTableRows(this.state.filteredData, providers);
tbody.innerHTML = this._generateTableRows(this.state.filteredData, columns);
});
}
}
@ -379,69 +437,109 @@ class ComparisonManager {
select1: document.getElementById(select1Id),
select2: document.getElementById(select2Id)
};
this.data = data;
this.isComparing = false;
this.handleCompare = this._generateCompareTable.bind(this);
this.services = []; // Store services array for indexed format
// Check if data is in indexed format
if (data.services && data.supported) {
this.services = data.services;
this.isIndexedFormat = true;
} else {
// Extract services from regular format
const firstHost = Object.values(data)[0];
this.services = firstHost ? Object.keys(firstHost) : [];
this.isIndexedFormat = false;
}
this._init();
}
_init() {
this._populateDropdowns();
this.elements.select1?.addEventListener('change', this.handleCompare);
this.elements.select2?.addEventListener('change', this.handleCompare);
if (this.elements.select1) {
this.elements.select1.addEventListener("change", this.handleCompare);
}
document.addEventListener('keydown', e => {
if (this.isComparing && e.key === 'Escape') this._closeComparison();
if (this.elements.select2) {
this.elements.select2.addEventListener("change", this.handleCompare);
}
document.addEventListener("keydown", (e) => {
if (this.isComparing && e.key === "Escape") {
this._closeComparison();
}
});
}
_populateDropdowns() {
const firstKey = Object.keys(this.data)[0];
const providers = firstKey ? Object.keys(this.data[firstKey]) : [];
const optionsHTML = '<option value="">Choose a service...</option>' +
providers.map(p => `<option value="${p}">${p}</option>`).join('');
// Use the stored services array
const optionsHtml = '<option value="">Choose a service...</option>' +
this.services.map(service =>
`<option value="${service}">${service}</option>`
).join("");
[this.elements.select1, this.elements.select2].forEach(select => {
if (select) select.innerHTML = optionsHTML;
if (select) {
select.innerHTML = optionsHtml;
}
});
}
_generateCompareTable() {
const provider1 = this.elements.select1?.value;
const provider2 = this.elements.select2?.value;
// Transform indexed data to regular format for comparison
_getHostData(hostName) {
if (this.isIndexedFormat) {
// Convert indexed format to regular format
const supportedIndices = this.data.supported[hostName] || [];
const hostData = {};
this.services.forEach((service, index) => {
hostData[service] = supportedIndices.includes(index) ? "yes" : "no";
});
return hostData;
} else {
// Return data as-is for regular format
return this.data[hostName] || {};
}
}
if (!provider1 || !provider2) {
_generateCompareTable() {
const service1 = this.elements.select1?.value;
const service2 = this.elements.select2?.value;
if (!service1 || !service2) {
this._showEmptyState();
return;
}
if (provider1 === provider2) {
if (service1 === service2) {
this._showSameProviderWarning();
return;
}
this.isComparing = true;
const loaderId = loadingManager.show(this.elements.container, 'Generating comparison...');
const loaderId = loadingManager.show(this.elements.container, "Generating comparison...");
requestAnimationFrame(() => {
this._renderComparisonTable(provider1, provider2);
this._renderComparisonTable(service1, service2);
loadingManager.hide(this.elements.container, loaderId);
});
}
_renderComparisonTable(provider1, provider2) {
const stats = this._calculateComparisonStats(provider1, provider2);
const fragment = document.createDocumentFragment();
const wrapper = document.createElement('div');
_renderComparisonTable(service1, service2) {
const stats = this._calculateComparisonStats(service1, service2);
const fragment = document.createDocumentFragment();
const wrapper = document.createElement("div");
wrapper.innerHTML = `
<div class="comparison-header">
<h3>Comparing ${provider1} vs ${provider2}</h3>
<h3>Comparing ${service1} vs ${service2}</h3>
<div class="comparison-stats">
<div class="stat"><span class="stat-label">Shared Support</span><span class="stat-value">${stats.shared}</span></div>
<div class="stat"><span class="stat-label">${provider1} Only</span><span class="stat-value">${stats.provider1Only}</span></div>
<div class="stat"><span class="stat-label">${provider2} Only</span><span class="stat-value">${stats.provider2Only}</span></div>
<div class="stat"><span class="stat-label">${service1} Only</span><span class="stat-value">${stats.service1Only}</span></div>
<div class="stat"><span class="stat-label">${service2} Only</span><span class="stat-value">${stats.service2Only}</span></div>
</div>
<div class="comparison-actions">
<button id="close-compare" class="btn btn-secondary">
@ -473,111 +571,147 @@ class ComparisonManager {
<thead>
<tr>
<th>Service Name</th>
<th class="provider-header ${provider1.toLowerCase()}">${provider1}</th>
<th class="provider-header ${provider2.toLowerCase()}">${provider2}</th>
<th class="provider-header ${service1.toLowerCase().replace(/\s+/g, '-')}">${service1}</th>
<th class="provider-header ${service2.toLowerCase().replace(/\s+/g, '-')}">${service2}</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${this._generateComparisonRows(provider1, provider2)}
${this._generateComparisonRows(service1, service2)}
</tbody>
</table>
</div>
`;
fragment.appendChild(wrapper);
this.elements.container.innerHTML = '';
this.elements.container.innerHTML = "";
this.elements.container.appendChild(fragment);
this.elements.container.style.display = 'block';
this.elements.container.style.display = "block";
this._attachComparisonEvents();
requestAnimationFrame(() => {
this.elements.container.classList.add('comparison-visible');
this.elements.container.classList.add("comparison-visible");
});
}
_generateComparisonRows(provider1, provider2) {
_generateComparisonRows(service1, service2) {
const rows = [];
const checkSvg = '<svg class="table-check" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><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>';
const crossSvg = '<svg class="table-cross" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><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>';
const checkIcon = '<svg class="table-check" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><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>';
const crossIcon = '<svg class="table-cross" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><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>';
for (const [host, providerData] of Object.entries(this.data)) {
const support1 = providerData[provider1] === 'yes';
const support2 = providerData[provider2] === 'yes';
// Get all hosts (keys from the original data structure)
const hosts = this.isIndexedFormat ? Object.keys(this.data.supported) : Object.keys(this.data);
let statusClass = 'neither-supported';
let statusText = 'Neither';
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`; }
for (const host of hosts) {
const hostData = this._getHostData(host);
const supported1 = hostData[service1] === "yes";
const supported2 = hostData[service2] === "yes";
let statusClass = "neither-supported";
let statusText = "Neither";
if (supported1 && supported2) {
statusClass = "both-supported";
statusText = "Both";
} else if (supported1) {
statusClass = "service1-only";
statusText = `${service1} only`;
} else if (supported2) {
statusClass = "service2-only";
statusText = `${service2} only`;
}
rows.push(`
<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 ? checkSvg : crossSvg}</span>
<td class="support-status ${supported1 ? "supported" : "not-supported"}">
<span class="status-indicator" aria-label="${supported1 ? "Supported" : "Not supported"}">
${supported1 ? checkIcon : crossIcon}
</span>
</td>
<td class="support-status ${support2 ? 'supported' : 'not-supported'}">
<span class="status-indicator" aria-label="${support2 ? 'Supported' : 'Not supported'}">${support2 ? checkSvg : crossSvg}</span>
<td class="support-status ${supported2 ? "supported" : "not-supported"}">
<span class="status-indicator" aria-label="${supported2 ? "Supported" : "Not supported"}">
${supported2 ? checkIcon : crossIcon}
</span>
</td>
<td class="status-text"><span class="status-badge ${statusClass}">${statusText}</span></td>
</tr>
`);
}
return rows.join('');
return rows.join("");
}
_calculateComparisonStats(provider1, provider2) {
let shared = 0, provider1Only = 0, provider2Only = 0;
for (const providerData of Object.values(this.data)) {
const s1 = providerData[provider1] === 'yes';
const s2 = providerData[provider2] === 'yes';
if (s1 && s2) shared++;
else if (s1) provider1Only++;
else if (s2) provider2Only++;
_calculateComparisonStats(service1, service2) {
let shared = 0;
let service1Only = 0;
let service2Only = 0;
// Get all hosts
const hosts = this.isIndexedFormat ? Object.keys(this.data.supported) : Object.keys(this.data);
for (const host of hosts) {
const hostData = this._getHostData(host);
const supported1 = hostData[service1] === "yes";
const supported2 = hostData[service2] === "yes";
if (supported1 && supported2) {
shared++;
} else if (supported1) {
service1Only++;
} else if (supported2) {
service2Only++;
}
return { shared, provider1Only, provider2Only };
}
return { shared, service1Only, service2Only };
}
_attachComparisonEvents() {
const closeBtn = this.elements.container.querySelector('#close-compare');
closeBtn?.addEventListener('click', () => this._closeComparison());
const closeBtn = this.elements.container.querySelector("#close-compare");
if (closeBtn) {
closeBtn.addEventListener("click", () => this._closeComparison());
}
this.elements.container.addEventListener('change', e => {
if (e.target.name === 'comparison-filter') {
this.elements.container.addEventListener("change", (e) => {
if (e.target.name === "comparison-filter") {
this._filterComparison(e.target.value);
}
});
}
_filterComparison(filter) {
const rows = this.elements.container.querySelectorAll('.comparison-row');
_filterComparison(filterValue) {
const rows = this.elements.container.querySelectorAll(".comparison-row");
let visibleCount = 0;
rows.forEach(row => {
const status = row.dataset.status;
const show = filter === 'all' ||
(filter === 'both' && status === 'both-supported') ||
(filter === 'different' && (status === 'provider1-only' || status === 'provider2-only'));
row.style.display = show ? '' : 'none';
if (show) visibleCount++;
const shouldShow =
filterValue === "all" ||
(filterValue === "both" && status === "both-supported") ||
(filterValue === "different" && (status === "service1-only" || status === "service2-only"));
row.style.display = shouldShow ? "" : "none";
if (shouldShow) visibleCount++;
});
let indicator = this.elements.container.querySelector('.filter-results');
if (!indicator) {
indicator = document.createElement('div');
indicator.className = 'filter-results';
const tableWrapper = this.elements.container.querySelector('.table-wrapper');
tableWrapper.parentNode.insertBefore(indicator, tableWrapper);
let resultsElement = this.elements.container.querySelector(".filter-results");
if (!resultsElement) {
resultsElement = document.createElement("div");
resultsElement.className = "filter-results";
const tableWrapper = this.elements.container.querySelector(".table-wrapper");
tableWrapper.parentNode.insertBefore(resultsElement, tableWrapper);
}
indicator.textContent = `Showing ${visibleCount} services`;
resultsElement.textContent = `Showing ${visibleCount} services`;
}
_closeComparison() {
this.elements.container.classList.add('comparison-hiding');
this.elements.container.classList.add("comparison-hiding");
setTimeout(() => {
this.elements.container.style.display = 'none';
this.elements.container.classList.remove('comparison-visible', 'comparison-hiding');
this.elements.container.style.display = "none";
this.elements.container.classList.remove("comparison-visible", "comparison-hiding");
this.isComparing = false;
this._showEmptyState();
}, 250);
@ -591,7 +725,7 @@ class ComparisonManager {
<p>Select two services above to see a detailed comparison</p>
</div>
`;
this.elements.container.style.display = 'block';
this.elements.container.style.display = "block";
}
_showSameProviderWarning() {
@ -602,10 +736,9 @@ class ComparisonManager {
<p>Please select two different services to compare</p>
</div>
`;
this.elements.container.style.display = 'block';
this.elements.container.style.display = "block";
}
}
/* ==============================
Pricing Manager
============================== */
@ -936,7 +1069,7 @@ document.addEventListener('DOMContentLoaded', async () => {
loadingManager.show('#pricing-table-container', 'Loading pricing data...')
];
const dataUrls = ['./json/file-hosts.json', './json/adult-hosts.json', './json/pricing.json'];
const dataUrls = ['./json/file-hosts-optimized.json', './json/adult-hosts-optimized.json', './json/pricing.json'];
const fetchPromises = dataUrls.map(url =>
fetch(url).then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status} for ${url}`)))
);
@ -966,7 +1099,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} else {
console.error(`Error loading data (${dataUrls[index]}):`, result.reason);
const el = document.querySelector(containers[index]);
if (el) el.innerHTML = `<div class="error-state"><p>Failed to load data: ${dataUrls[index]}</p></div>`;
if (el) el.innerHTML = `<div class="error-state"><p>Failed to load data: <span class="error-dataurl">${dataUrls[index]}</span></p></div>`;
}
});

View file

@ -1,12 +0,0 @@
const fs = require('fs');
const path = require('path');
const file = path.resolve(__dirname, '..', 'dist', 'index.html');
let html = fs.readFileSync(file, 'utf8');
html = html
.replace(/href=["']\.\/css\/styles\.css["']/g, 'href="./css/styles-min.css"')
.replace(/src=["']js\/app\.js["']/g, 'src="js/app-min.js"');
fs.writeFileSync(file, html, 'utf8');
console.log('Replaced asset links in dist/index.html');

View file

@ -11,7 +11,8 @@
"scripts": {
"minify:css": "cleancss -o dist/css/styles-min.css dist/css/styles.css",
"minify:js": "terser dist/js/app.js -o dist/js/app-min.js --compress --mangle",
"build": "npm run minify:css && npm run minify:js && workbox generateSW workbox-config.js",
"optimize-json": "chmod +x scripts/json-optimizer.sh && scripts/json-optimizer.sh dist/json/file-hosts.json && scripts/json-optimizer.sh dist/json/adult-hosts.json",
"build": "npm run minify:css && npm run minify:js && npm run optimize-json && workbox generateSW workbox-config.js",
"postbuild": "node scripts/replace-assets.js"
},
"author": "fynks",

191
scripts/json-optimizer.sh Executable file
View file

@ -0,0 +1,191 @@
#!/bin/bash
# JSON Optimizer Script - Converts host data to indexed format
# Usage: ./scripts/json-optimizer.sh file-hosts.json
# ./scripts/json-optimizer.sh adult-hosts.json
# ./scripts/json-optimizer.sh pricing.json
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Default values
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEFAULT_INPUT_DIR="."
DEFAULT_OUTPUT_DIR="."
# Check if at least one argument is provided
if [ $# -eq 0 ]; then
print_error "No input file specified"
echo "Usage: $0 <input-file.json> [output-file.json]"
echo "Examples:"
echo " $0 file-hosts.json"
echo " $0 ./json/adult-hosts.json"
echo " $0 pricing.json pricing-optimized.json"
exit 1
fi
# Get input file
INPUT_FILE="$1"
# Generate output filename if not provided
if [ $# -eq 2 ]; then
OUTPUT_FILE="$2"
else
# Extract filename without path and extension
FILENAME=$(basename "$INPUT_FILE")
NAME="${FILENAME%.*}"
EXT="${FILENAME##*.}"
OUTPUT_FILE="${NAME}-optimized.${EXT}"
fi
# Check if input file exists
if [ ! -f "$INPUT_FILE" ]; then
print_error "Input file '$INPUT_FILE' not found"
exit 1
fi
# Create output directory if it doesn't exist
OUTPUT_DIR=$(dirname "$OUTPUT_FILE")
if [ ! -d "$OUTPUT_DIR" ] && [ "$OUTPUT_DIR" != "." ]; then
print_status "Creating output directory: $OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
fi
print_status "Processing: $INPUT_FILE"
print_status "Output will be saved to: $OUTPUT_FILE"
# Python script to handle the conversion
python3 -c "
import json
import sys
import os
from collections import defaultdict
def convert_to_indexed_format(input_file, output_file):
try:
# Read the input JSON
print('Reading input file...')
with open(input_file, 'r') as f:
data = json.load(f)
host_count = len(data)
print(f'Loaded {host_count:,} hosts')
# Handle empty data
if not data:
print('Warning: Input file is empty')
output_data = {'services': [], 'supported': {}}
with open(output_file, 'w') as f:
json.dump(output_data, f, separators=(',', ':'))
return True
# Extract all services (keys from entries)
all_services = set()
for host_data in data.values():
all_services.update(host_data.keys())
services = sorted(list(all_services))
service_count = len(services)
print(f'Found {service_count} services: {\", \".join(services[:5])}{\"...\" if service_count > 5 else \"\"}')
# Create service to index mapping
service_to_index = {service: idx for idx, service in enumerate(services)}
# Convert data to indexed format
print('Converting data to indexed format...')
supported = {}
total_support_instances = 0
for host, host_data in data.items():
# Get indices of services with 'yes' values (case insensitive)
supported_indices = [
service_to_index[service]
for service, value in host_data.items()
if str(value).lower() == 'yes'
]
supported[host] = sorted(supported_indices)
total_support_instances += len(supported_indices)
# Create output structure
output_data = {
'services': services,
'supported': supported
}
# Write optimized JSON (compact format)
print('Writing optimized JSON...')
with open(output_file, 'w') as f:
json.dump(output_data, f, separators=(',', ':'))
# Get file sizes for comparison
original_size = os.path.getsize(input_file)
new_size = os.path.getsize(output_file)
reduction = ((original_size - new_size) / original_size) * 100 if original_size > 0 else 0
print('✅ Conversion successful!')
print(f'📁 Original size: {original_size:,} bytes')
print(f'📁 New size: {new_size:,} bytes')
print(f'📉 Size reduction: {reduction:.1f}%')
print(f'💾 Output saved to: {output_file}')
# Show statistics
avg_support_per_host = total_support_instances / host_count if host_count > 0 else 0
print('📈 Statistics:')
print(f' - Hosts: {host_count:,}')
print(f' - Services: {service_count:,}')
print(f' - Total support instances: {total_support_instances:,}')
print(f' - Average support per host: {avg_support_per_host:.1f}')
return True
except json.JSONDecodeError as e:
print(f'❌ JSON parsing error: {str(e)}')
return False
except Exception as e:
print(f'❌ Error during conversion: {str(e)}')
return False
# Run the conversion
success = convert_to_indexed_format('$INPUT_FILE', '$OUTPUT_FILE')
if not success:
sys.exit(1)
" || {
print_error "Python conversion failed. Please ensure Python 3 is installed."
exit 1
}
print_success "Conversion completed successfully!"
# Optional: Show file size comparison in bash as well
if command -v stat >/dev/null 2>&1; then
ORIGINAL_SIZE=$(stat -f%z "$INPUT_FILE" 2>/dev/null || stat -c%s "$INPUT_FILE" 2>/dev/null)
NEW_SIZE=$(stat -f%z "$OUTPUT_FILE" 2>/dev/null || stat -c%s "$OUTPUT_FILE" 2>/dev/null)
if [ $? -eq 0 ] && [ $ORIGINAL_SIZE -gt 0 ]; then
REDUCTION_PERCENTAGE=$(( (ORIGINAL_SIZE - NEW_SIZE) * 100 / ORIGINAL_SIZE ))
print_status "Final size reduction: ${REDUCTION_PERCENTAGE}%"
fi
fi