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