diff --git a/dist/css/styles.css b/dist/css/styles.css index da4509f..7679277 100644 --- a/dist/css/styles.css +++ b/dist/css/styles.css @@ -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; diff --git a/dist/index.html b/dist/index.html index cdd352f..479193b 100644 --- a/dist/index.html +++ b/dist/index.html @@ -40,7 +40,7 @@ - + + diff --git a/dist/js/app-min.js b/dist/js/app-min.js new file mode 100644 index 0000000..6ca0b42 --- /dev/null +++ b/dist/js/app-min.js @@ -0,0 +1 @@ +const Utils=(()=>{const e=new WeakMap;return{debounce(t,n=300,s=!1){if(e.has(t))return e.get(t);let a;const r=function(...e){const r=s&&!a;clearTimeout(a),r&&t.apply(this,e),a=setTimeout(()=>{a=null,s||t.apply(this,e)},n)};return e.set(t,r),r},throttle(e,t=100){let n=!1;return function(...s){n||(e.apply(this,s),n=!0,requestAnimationFrame(()=>{setTimeout(()=>n=!1,t)}))}},animateOnScroll:(()=>{let e;return(t,n={})=>{"IntersectionObserver"in window&&(e||(e=new IntersectionObserver(t=>{t.forEach(t=>{t.isIntersecting&&(t.target.classList.add("animate-in"),e.unobserve(t.target))})},{threshold:.1,rootMargin:"0px 0px -50px 0px",...n})),t.forEach(t=>e.observe(t)))}})()}})();class LoadingManager{constructor(){this.activeLoaders=new Map,this.template=this.createTemplate()}createTemplate(){const e=document.createElement("template");return e.innerHTML='\n
\n
\n
\n
\n
\n
\n
\n

\n
\n
\n ',e}show(e,t="Loading..."){const n="string"==typeof e?document.querySelector(e):e;if(!n)return null;const s=`loader-${Date.now()}-${Math.random().toString(36).slice(2)}`,a=this.template.content.cloneNode(!0).firstElementChild;a.dataset.loaderId=s,a.querySelector(".loading-text").textContent=t;return"static"===getComputedStyle(n).position&&(n.style.position="relative"),n.appendChild(a),this.activeLoaders.set(s,a),requestAnimationFrame(()=>a.classList.add("loading-overlay--visible")),s}hide(e,t){const n=this.activeLoaders.get(t);n&&(n.classList.add("loading-overlay--hiding"),this.activeLoaders.delete(t),setTimeout(()=>n.remove(),250))}hideAll(){this.activeLoaders.forEach((e,t)=>this.hide(null,t))}}class TableManager{constructor(e,t,n,s={}){this.elements={container:document.getElementById(e),searchInput:document.getElementById(t),clearIcon:document.getElementById(n)},this.options={sortable:!0,filterable:!0,pagination:!1,itemsPerPage:50,...s},this.state={currentData:{},filteredData:{},currentSort:{column:null,direction:"asc"},currentPage:1,services:[],isIndexedFormat:!1},this.handleSearch=Utils.debounce(this._performSearch.bind(this),250),this.handleSort=this._performSort.bind(this),this._init()}_init(){this.elements.searchInput&&(this.elements.searchInput.addEventListener("input",this.handleSearch),this.elements.searchInput.addEventListener("keydown",e=>{"Escape"===e.key&&this._clearSearch()})),this.elements.clearIcon&&this.elements.clearIcon.addEventListener("click",()=>this._clearSearch())}_transformIndexedData(e){const{services:t,supported:n}=e,s={};for(const[e,a]of Object.entries(n))s[e]={},t.forEach((t,n)=>{s[e][t]=a.includes(n)?"yes":"no"});return this.state.services=t,this.state.isIndexedFormat=!0,s}generateTable(e={}){if(!this.elements.container)return;let t;if(e.services&&e.supported)t=this._transformIndexedData(e);else{t=e,this.state.isIndexedFormat=!1;const n=Object.values(t)[0];this.state.services=n?Object.keys(n):[]}if(!Object.keys(t).length)return void(this.elements.container.innerHTML='

No data available.

');this.state.currentData=t,this.state.filteredData={...t};const n=Object.keys(t)[0],s=n?Object.keys(t[n]):[],a=this.elements.container.id.replace("-container",""),r=document.createDocumentFragment(),i=document.createElement("div");i.className="table-wrapper";const o=document.createElement("table");o.id=a,o.className="enhanced-table",o.setAttribute("aria-label","Service Comparison Table");const l=document.createElement("thead");l.innerHTML=this._generateTableHeader(s),o.appendChild(l);const c=document.createElement("tbody");c.innerHTML=this._generateTableRows(this.state.filteredData,s),o.appendChild(c),i.appendChild(o),r.appendChild(i),this.elements.container.innerHTML="",this.elements.container.appendChild(r),this._attachTableEvents()}_generateTableHeader(e){return`\n \n \n Service Name\n \n \n ${e.map(e=>`\n \n ${e}\n \n \n `).join("")}\n \n `}_generateTableRows(e,t){const n=[];for(const[s,a]of Object.entries(e))n.push(`\n \n \n
\n ${s}\n
\n \n ${t.map(e=>{const t="yes"===a[e];return`\n \n \n ${t?'':''}\n \n \n `}).join("")}\n \n `);return n.join("")}_attachTableEvents(){if(!this.options.sortable)return;const e=this.elements.container.querySelector("thead");e&&(e.addEventListener("click",this.handleSort),e.addEventListener("keydown",e=>{const t=e.target.closest(".sortable");!t||"Enter"!==e.key&&" "!==e.key||(e.preventDefault(),this.handleSort({target:t}))}))}_performSearch(){const e=(this.elements.searchInput?.value||"").toLowerCase().trim();e?(this.state.filteredData=Object.fromEntries(Object.entries(this.state.currentData).filter(([t])=>t.toLowerCase().includes(e))),this.elements.clearIcon&&(this.elements.clearIcon.style.display="block")):(this.state.filteredData={...this.state.currentData},this.elements.clearIcon&&(this.elements.clearIcon.style.display="none")),this._updateTableContent(),this._updateSearchResults(e)}_clearSearch(){this.elements.searchInput&&(this.elements.searchInput.value=""),this.state.filteredData={...this.state.currentData},this.elements.clearIcon&&(this.elements.clearIcon.style.display="none"),this._updateTableContent(),this._updateSearchResults("")}_updateSearchResults(e){if(!this.elements.container)return;let t=this.elements.container.querySelector(".search-results");if(e){const e=Object.keys(this.state.filteredData).length,n=Object.keys(this.state.currentData).length;t||(t=document.createElement("div"),t.className="search-results",this.elements.container.insertBefore(t,this.elements.container.firstChild)),t.textContent=`Showing ${e} of ${n} services`,t.style.display="block",t.style.textAlign="center",t.style.padding="0.5em 0"}else t&&(t.style.display="none")}_performSort(e){const t=e.target.closest(".sortable");if(!t)return;const n=t.dataset.column,{currentSort:s}=this.state,a=s.column===n&&"asc"===s.direction?"desc":"asc";this.state.currentSort={column:n,direction:a},this.elements.container.querySelectorAll("th.sortable").forEach(e=>{e.setAttribute("aria-sort","none"),e.classList.remove("sorted-asc","sorted-desc")}),t.setAttribute("aria-sort","asc"===a?"ascending":"descending"),t.classList.add(`sorted-${a}`),this._sortData(),this._updateTableContent()}_sortData(){const{column:e,direction:t}=this.state.currentSort,n=Object.entries(this.state.filteredData);n.sort(([n,s],[a,r])=>{let i,o;"service"===e?(i=n.toLowerCase(),o=a.toLowerCase()):(i="yes"===s[e]?1:0,o="yes"===r[e]?1:0);let l=0;return i>o?l=1:i{e.innerHTML=this._generateTableRows(this.state.filteredData,n)})}}class ComparisonManager{constructor(e,t,n,s){if(this.elements={container:document.getElementById(e),select1:document.getElementById(t),select2:document.getElementById(n)},this.data=s,this.isComparing=!1,this.handleCompare=this._generateCompareTable.bind(this),this.services=[],s.services&&s.supported)this.services=s.services,this.isIndexedFormat=!0;else{const e=Object.values(s)[0];this.services=e?Object.keys(e):[],this.isIndexedFormat=!1}this._init()}_init(){this._populateDropdowns(),this.elements.select1&&this.elements.select1.addEventListener("change",this.handleCompare),this.elements.select2&&this.elements.select2.addEventListener("change",this.handleCompare),document.addEventListener("keydown",e=>{this.isComparing&&"Escape"===e.key&&this._closeComparison()})}_populateDropdowns(){const e=''+this.services.map(e=>``).join("");[this.elements.select1,this.elements.select2].forEach(t=>{t&&(t.innerHTML=e)})}_getHostData(e){if(this.isIndexedFormat){const t=this.data.supported[e]||[],n={};return this.services.forEach((e,s)=>{n[e]=t.includes(s)?"yes":"no"}),n}return this.data[e]||{}}_generateCompareTable(){const e=this.elements.select1?.value,t=this.elements.select2?.value;if(!e||!t)return void this._showEmptyState();if(e===t)return void this._showSameProviderWarning();this.isComparing=!0;const n=loadingManager.show(this.elements.container,"Generating comparison...");requestAnimationFrame(()=>{this._renderComparisonTable(e,t),loadingManager.hide(this.elements.container,n)})}_renderComparisonTable(e,t){const n=this._calculateComparisonStats(e,t),s=document.createDocumentFragment(),a=document.createElement("div");a.innerHTML=`\n
\n

Comparing ${e} vs ${t}

\n
\n
Shared Support${n.shared}
\n
${e} Only${n.service1Only}
\n
${t} Only${n.service2Only}
\n
\n
\n \n
\n
\n\n
\n \n \n \n
\n\n
\n \n \n \n \n \n \n \n \n \n \n ${this._generateComparisonRows(e,t)}\n \n
Service Name${e}${t}Status
\n
\n `,s.appendChild(a),this.elements.container.innerHTML="",this.elements.container.appendChild(s),this.elements.container.style.display="block",this._attachComparisonEvents(),requestAnimationFrame(()=>{this.elements.container.classList.add("comparison-visible")})}_generateComparisonRows(e,t){const n=[],s='',a='',r=this.isIndexedFormat?Object.keys(this.data.supported):Object.keys(this.data);for(const i of r){const r=this._getHostData(i),o="yes"===r[e],l="yes"===r[t];let c="neither-supported",d="Neither";o&&l?(c="both-supported",d="Both"):o?(c="service1-only",d=`${e} only`):l&&(c="service2-only",d=`${t} only`),n.push(`\n \n ${i}\n \n \n ${o?s:a}\n \n \n \n \n ${l?s:a}\n \n \n ${d}\n \n `)}return n.join("")}_calculateComparisonStats(e,t){let n=0,s=0,a=0;const r=this.isIndexedFormat?Object.keys(this.data.supported):Object.keys(this.data);for(const i of r){const r=this._getHostData(i),o="yes"===r[e],l="yes"===r[t];o&&l?n++:o?s++:l&&a++}return{shared:n,service1Only:s,service2Only:a}}_attachComparisonEvents(){const e=this.elements.container.querySelector("#close-compare");e&&e.addEventListener("click",()=>this._closeComparison()),this.elements.container.addEventListener("change",e=>{"comparison-filter"===e.target.name&&this._filterComparison(e.target.value)})}_filterComparison(e){const t=this.elements.container.querySelectorAll(".comparison-row");let n=0;t.forEach(t=>{const s=t.dataset.status,a="all"===e||"both"===e&&"both-supported"===s||"different"===e&&("service1-only"===s||"service2-only"===s);t.style.display=a?"":"none",a&&n++});let s=this.elements.container.querySelector(".filter-results");if(!s){s=document.createElement("div"),s.className="filter-results";const e=this.elements.container.querySelector(".table-wrapper");e.parentNode.insertBefore(s,e)}s.textContent=`Showing ${n} services`}_closeComparison(){this.elements.container.classList.add("comparison-hiding"),setTimeout(()=>{this.elements.container.style.display="none",this.elements.container.classList.remove("comparison-visible","comparison-hiding"),this.isComparing=!1,this._showEmptyState()},250)}_showEmptyState(){this.elements.container.innerHTML='\n
\n \n

Ready to Compare

\n

Select two services above to see a detailed comparison

\n
\n ',this.elements.container.style.display="block"}_showSameProviderWarning(){this.elements.container.innerHTML='\n
\n
⚠️
\n

Same Service Selected

\n

Please select two different services to compare

\n
\n ',this.elements.container.style.display="block"}}class PricingManager{constructor(e){this.container=document.getElementById(`${e}-table-container`),this.currentData=null,this._onMouseOver=this._onMouseOver.bind(this),this._onMouseOut=this._onMouseOut.bind(this),this._onMouseLeaveContainer=this._onMouseLeaveContainer.bind(this)}generatePricingTable(e={}){if(!this.container)return;if(!e.plans?.length)return void(this.container.innerHTML='

Error: Invalid pricing data structure.

');this.currentData=e;const t=Object.keys(e.plans[0]).filter(e=>"name"!==e),n=document.createDocumentFragment(),s=document.createElement("div");s.className="table-wrapper";const a=document.createElement("table");a.id="pricing-table",a.className="pricing-table",a.setAttribute("aria-label","Service Pricing Table");const r=document.createElement("thead");r.innerHTML=`\n \n Plans\n ${t.map(e=>`\n \n
\n ${e}\n
\n \n `).join("")}\n \n `;const i=document.createElement("tbody");i.innerHTML=e.plans.map((e,n)=>`\n \n ${e.name}\n ${t.map(t=>{const n=e[t],s=!isNaN(parseFloat(n));return`\n \n
\n ${n}\n ${s?'/month':""}\n
\n \n `}).join("")}\n \n `).join(""),a.appendChild(r),a.appendChild(i),s.appendChild(a),n.appendChild(s),this.container.innerHTML="",this.container.appendChild(n),this._setupPricingFeatures()}_setupPricingFeatures(){this.container.addEventListener("mouseover",this._onMouseOver),this.container.addEventListener("mouseout",this._onMouseOut),this.container.addEventListener("mouseleave",this._onMouseLeaveContainer)}_onMouseOver(e){const t=e.target.closest(".price-cell");if(!t||!this.container.contains(t))return;const n=Array.from(t.parentNode.children).indexOf(t);n>=0&&this._highlightColumn(n+1)}_onMouseOut(e){const t=e.relatedTarget;t&&this.container.contains(t)||this._removeColumnHighlight()}_onMouseLeaveContainer(){this._removeColumnHighlight()}_highlightColumn(e){const t=this.container.querySelector("table");if(!t)return;const n=t.querySelectorAll(`tbody td:nth-child(${e+1}), thead th:nth-child(${e+1})`);this._removeColumnHighlight(),n.forEach(e=>e.classList.add("highlighted"))}_removeColumnHighlight(){this.container.querySelectorAll(".highlighted").forEach(e=>e.classList.remove("highlighted"))}}class PerformanceMonitor{constructor(){this.marks=new Map,this.mark("start")}mark(e){this.marks.set(e,performance.now())}measure(e,t="start"){const n=this.marks.get(t)||performance.now();return performance.now()-n}log(e,t="start"){console.log(`${e}: ${this.measure(e,t).toFixed(2)}ms`)}}function setupURLComparison(){const e=new URLSearchParams(window.location.search),t=e.get("compare"),n=e.get("with");t&&n&&requestAnimationFrame(()=>{const e=document.getElementById("provider1"),s=document.getElementById("provider2");e&&s&&(e.value=t,s.value=n,e.dispatchEvent(new Event("change")))})}function setupScrollAnimations(){const e=document.querySelectorAll(".section, .status-card, .referral-card");e.length&&Utils.animateOnScroll(e)}function setupOfflineDetection(){const e=()=>{console.log(navigator.onLine?"Connection restored":"You are offline. Some features may not work.")};window.addEventListener("online",e),window.addEventListener("offline",e)}function setupBackToTop(){const e=document.getElementById("backToTop");if(!e)return;const t=Utils.throttle(()=>{window.pageYOffset>300?e.classList.add("visible"):e.classList.remove("visible")},150);window.addEventListener("scroll",t,{passive:!0}),e.addEventListener("click",()=>window.scrollTo({top:0,behavior:"smooth"}))}function setupNavHighlight(){const e=Array.from(document.querySelectorAll('.header-nav .nav-link[href^="#"]'));if(!("IntersectionObserver"in window)||!e.length)return;const t=e.map(e=>document.querySelector(e.getAttribute("href"))).filter(Boolean),n=new Map;e.forEach(e=>n.set(e.getAttribute("href"),e));const s=new IntersectionObserver(t=>{t.forEach(t=>{const s=`#${t.target.id}`,a=n.get(s);a&&t.isIntersecting&&(e.forEach(e=>e.removeAttribute("aria-current")),a.setAttribute("aria-current","page"))})},{rootMargin:"-40% 0px -50% 0px",threshold:.1});t.forEach(e=>s.observe(e))}function setupAgeVerification(){const e=document.getElementById("age-verification-overlay"),t=document.getElementById("adult-content"),n=document.getElementById("confirm-age"),s=document.getElementById("deny-age"),a=document.querySelector(".page-wrapper"),r=document.getElementById("adult-hosts");if(!e||!r)return;const i=e.querySelector(".age-verification-modal"),o=e.querySelector(".age-verification-content h3");if(i&&(i.setAttribute("role","dialog"),i.setAttribute("aria-modal","true"),o)){const e="age-title";o.id=e,i.setAttribute("aria-labelledby",e)}const l=e=>{a&&("inert"in a?a.inert=e:a.setAttribute("aria-hidden",String(e)))};let c=null;const d=()=>{e.style.display="flex",e.setAttribute("aria-hidden","false"),l(!0),c=(e=>{const t=Array.from(e.querySelectorAll('a[href], area[href], button, input, select, textarea, details, summary, [tabindex]:not([tabindex="-1"])')).filter(e=>!e.hasAttribute("disabled")&&"true"!==e.getAttribute("aria-hidden")),n=t[0],a=t[t.length-1],r=e=>{if("Escape"===e.key)s?.click();else if("Tab"===e.key){if(0===t.length)return void e.preventDefault();e.shiftKey&&document.activeElement===n?(e.preventDefault(),a.focus()):e.shiftKey||document.activeElement!==a||(e.preventDefault(),n.focus())}};return e.addEventListener("keydown",r),n?.focus(),()=>e.removeEventListener("keydown",r)})(i||e)},h=()=>{e.style.display="none",e.setAttribute("aria-hidden","true"),l(!1),"function"==typeof c&&c()};if("true"===localStorage.getItem("age-confirmed")&&(e.style.display="none",t&&(t.style.display="block")),n?.addEventListener("click",()=>{localStorage.setItem("age-confirmed","true"),t&&(t.style.display="block"),h()}),s?.addEventListener("click",()=>{document.getElementById("what-are-debrid")?.scrollIntoView({behavior:"smooth"}),h()}),"IntersectionObserver"in window){new IntersectionObserver(e=>{e.forEach(e=>{e.isIntersecting&&"true"!==localStorage.getItem("age-confirmed")&&d()})},{threshold:.1}).observe(r)}}const loadingManager=new LoadingManager,performanceMonitor=new PerformanceMonitor;document.addEventListener("DOMContentLoaded",async()=>{performanceMonitor.mark("dom-ready"),performanceMonitor.log("DOM Ready");try{const e=new TableManager("file-hosts-table-container","host-search-input","clear-host-search"),t=new TableManager("adult-hosts-table-container","adult-host-search-input","clear-adult-host-search"),n=new PricingManager("pricing"),s=[loadingManager.show("#file-hosts-table-container","Loading file hosts..."),loadingManager.show("#adult-hosts-table-container","Loading adult hosts..."),loadingManager.show("#pricing-table-container","Loading pricing data...")],a=["./json/file-hosts-optimized.json","./json/adult-hosts-optimized.json","./json/pricing.json"],r=a.map(e=>fetch(e).then(t=>t.ok?t.json():Promise.reject(new Error(`HTTP ${t.status} for ${e}`))));(await Promise.allSettled(r)).forEach((r,i)=>{const o=["#file-hosts-table-container","#adult-hosts-table-container","#pricing-table-container"];if(loadingManager.hide(o[i],s[i]),"fulfilled"===r.status)switch(i){case 0:e.generateTable(r.value),new ComparisonManager("compare-table-container","provider1","provider2",r.value);break;case 1:t.generateTable(r.value);break;case 2:n.generatePricingTable(r.value)}else{console.error(`Error loading data (${a[i]}):`,r.reason);const e=document.querySelector(o[i]);e&&(e.innerHTML=`

Failed to load data: ${a[i]}

`)}}),setupURLComparison(),setupScrollAnimations(),setupOfflineDetection(),setupBackToTop(),setupNavHighlight(),setupAgeVerification(),performanceMonitor.mark("fully-loaded"),performanceMonitor.log("Fully Loaded")}catch(e){console.error("Critical error:",e),loadingManager.hideAll()}}),window.addEventListener("error",e=>{console.error("Global error:",e.error||e.message||e)},{passive:!0}),window.addEventListener("unhandledrejection",e=>{console.error("Unhandled promise rejection:",e.reason)}),"serviceWorker"in navigator&&window.addEventListener("load",()=>{navigator.serviceWorker.register("/sw.js").catch(e=>{console.warn("Service worker registration failed:",e)})}); \ No newline at end of file diff --git a/dist/js/app.js b/dist/js/app.js index d5e5002..fa965e8 100644 --- a/dist/js/app.js +++ b/dist/js/app.js @@ -11,601 +11,734 @@ Utils ============================== */ const Utils = (() => { - const debounceCache = new WeakMap(); - - return { - debounce(func, delay = 300, immediate = false) { - if (debounceCache.has(func)) return debounceCache.get(func); - - let timeoutId; - const debounced = function(...args) { - const callNow = immediate && !timeoutId; - clearTimeout(timeoutId); - - if (callNow) func.apply(this, args); - - timeoutId = setTimeout(() => { - timeoutId = null; - if (!immediate) func.apply(this, args); - }, delay); - }; - - debounceCache.set(func, debounced); - return debounced; - }, - - throttle(func, limit = 100) { - let ticking = false; - return function(...args) { - if (!ticking) { - func.apply(this, args); - ticking = true; - requestAnimationFrame(() => { - setTimeout(() => { ticking = false; }, limit); - }); - } - }; - }, - - animateOnScroll: (() => { - let observer; - return (elements, options = {}) => { - if (!('IntersectionObserver' in window)) return; - if (!observer) { - observer = new IntersectionObserver((entries) => { - entries.forEach(entry => { - if (entry.isIntersecting) { - entry.target.classList.add('animate-in'); - observer.unobserve(entry.target); - } - }); - }, { - threshold: 0.1, - rootMargin: '0px 0px -50px 0px', - ...options - }); - } - elements.forEach(el => observer.observe(el)); - }; - })() - }; + const debounceMap = new WeakMap(); + return { + debounce(func, wait = 300, immediate = false) { + if (debounceMap.has(func)) return debounceMap.get(func); + let timeout; + const debounced = function (...args) { + const callNow = immediate && !timeout; + clearTimeout(timeout); + if (callNow) func.apply(this, args); + timeout = setTimeout(() => { + timeout = null; + if (!immediate) func.apply(this, args); + }, wait); + }; + debounceMap.set(func, debounced); + return debounced; + }, + throttle(func, limit = 100) { + let inThrottle = false; + return function (...args) { + if (!inThrottle) { + func.apply(this, args); + inThrottle = true; + requestAnimationFrame(() => { + setTimeout(() => inThrottle = false, limit); + }); + } + }; + }, + animateOnScroll: (() => { + let observer; + return (elements, options = {}) => { + if (!('IntersectionObserver' in window)) return; + if (!observer) { + observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + entry.target.classList.add("animate-in"); + observer.unobserve(entry.target); + } + }); + }, { + threshold: 0.1, + rootMargin: "0px 0px -50px 0px", + ...options + }); + } + elements.forEach((element) => observer.observe(element)); + }; + })() + }; })(); -/* ============================== - Loading Manager - ============================== */ class LoadingManager { - constructor() { - this.activeLoaders = new Map(); - this.template = this.createTemplate(); - } + constructor() { + this.activeLoaders = new Map(); + this.template = this.createTemplate(); + } - createTemplate() { - const template = document.createElement('template'); - template.innerHTML = ` -
-
-
-
-
-
-
-

-
-
- `; - return template; - } + createTemplate() { + const template = document.createElement("template"); + template.innerHTML = ` +
+
+
+
+
+
+
+

+
+
+ `; + 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 loaderId = `loader-${Date.now()}-${Math.random().toString(36).slice(2)}`; + 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(() => loaderElement.classList.add("loading-overlay--visible")); + return loaderId; + } - requestAnimationFrame(() => loader.classList.add('loading-overlay--visible')); - return loaderId; - } + hide(target, loaderId) { + const loaderElement = this.activeLoaders.get(loaderId); + if (loaderElement) { + loaderElement.classList.add("loading-overlay--hiding"); + this.activeLoaders.delete(loaderId); + setTimeout(() => loaderElement.remove(), 250); + } + } - hide(target, loaderId) { - const loader = this.activeLoaders.get(loaderId); - if (!loader) return; - loader.classList.add('loading-overlay--hiding'); - this.activeLoaders.delete(loaderId); - setTimeout(() => loader.remove(), 250); - } - - hideAll() { - this.activeLoaders.forEach((_, id) => this.hide(null, id)); - } + hideAll() { + this.activeLoaders.forEach((_, loaderId) => this.hide(null, loaderId)); + } } -/* ============================== - Table Manager (file/adult hosts) - ============================== */ class TableManager { - constructor(containerId, searchInputId, clearIconId, options = {}) { - this.elements = { - container: document.getElementById(containerId), - searchInput: document.getElementById(searchInputId), - clearIcon: document.getElementById(clearIconId) - }; + constructor(containerId, searchInputId, clearIconId, options = {}) { + this.elements = { + container: document.getElementById(containerId), + searchInput: document.getElementById(searchInputId), + clearIcon: document.getElementById(clearIconId) + }; - this.options = { sortable: true, filterable: true, pagination: false, itemsPerPage: 50, ...options }; - this.state = { - currentData: {}, - filteredData: {}, - currentSort: { column: null, direction: 'asc' }, - currentPage: 1 - }; + this.options = { + sortable: true, + filterable: true, + pagination: false, + itemsPerPage: 50, + ...options + }; - this.handleSearch = Utils.debounce(this._performSearch.bind(this), 250); - this.handleSort = this._performSort.bind(this); + this.state = { + currentData: {}, + filteredData: {}, + currentSort: { column: null, direction: "asc" }, + currentPage: 1, + services: [], // Store services array for indexed format + isIndexedFormat: false // Flag to track data format + }; - 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.handleSearch = Utils.debounce(this._performSearch.bind(this), 250); + this.handleSort = this._performSort.bind(this); + this._init(); } - if (this.elements.clearIcon) { - this.elements.clearIcon.addEventListener('click', () => this._clearSearch()); - } - } - - generateTable(data = {}) { - if (!this.elements.container) return; - if (!Object.keys(data).length) { - this.elements.container.innerHTML = '

No data available.

'; - return; - } - - this.state.currentData = data; - this.state.filteredData = { ...data }; - - const firstKey = Object.keys(data)[0]; - const providers = firstKey ? Object.keys(data[firstKey]) : []; - const tableId = this.elements.container.id.replace('-container', ''); - - const fragment = document.createDocumentFragment(); - const wrapper = document.createElement('div'); - wrapper.className = 'table-wrapper'; - - const table = document.createElement('table'); - table.id = tableId; - table.className = 'enhanced-table'; - table.setAttribute('aria-label', 'Service Comparison Table'); - - const thead = document.createElement('thead'); - thead.innerHTML = this._generateTableHeader(providers); - table.appendChild(thead); - - const tbody = document.createElement('tbody'); - tbody.innerHTML = this._generateTableRows(this.state.filteredData, providers); - table.appendChild(tbody); - - wrapper.appendChild(table); - fragment.appendChild(wrapper); - - this.elements.container.innerHTML = ''; - this.elements.container.appendChild(fragment); - - this._attachTableEvents(); - } - - _generateTableHeader(providers) { - return ` - - - Service Name - - - ${providers.map(provider => ` - - ${provider} - - - `).join('')} - - `; - } - - _generateTableRows(data, providers) { - const rows = []; - for (const [host, providerData] of Object.entries(data)) { - rows.push(` - - -
- ${host} -
- - ${providers.map(provider => { - const isSupported = providerData[provider] === 'yes'; - return ` - - - ${isSupported - ? '' - : '' - } - - - `; - }).join('')} - - `); - } - return rows.join(''); - } - - _attachTableEvents() { - if (!this.options.sortable) return; - 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 === ' ') { - e.preventDefault(); - this.handleSort({ target }); + _init() { + if (this.elements.searchInput) { + this.elements.searchInput.addEventListener("input", this.handleSearch); + this.elements.searchInput.addEventListener("keydown", (e) => { + if (e.key === "Escape") this._clearSearch(); + }); } - }); - } - } - _performSearch() { - const term = (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 { - this.state.filteredData = Object.fromEntries( - Object.entries(this.state.currentData).filter(([host]) => host.toLowerCase().includes(term)) - ); - if (this.elements.clearIcon) this.elements.clearIcon.style.display = 'block'; + if (this.elements.clearIcon) { + this.elements.clearIcon.addEventListener("click", () => this._clearSearch()); + } } - this._updateTableContent(); - this._updateSearchResults(term); - } - - _clearSearch() { - if (this.elements.searchInput) this.elements.searchInput.value = ''; - this.state.filteredData = { ...this.state.currentData }; - if (this.elements.clearIcon) this.elements.clearIcon.style.display = 'none'; - this._updateTableContent(); - this._updateSearchResults(''); - } - - _updateSearchResults(searchTerm) { - if (!this.elements.container) return; - let indicator = this.elements.container.querySelector('.search-results'); - - if (searchTerm) { - const resultCount = 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); - } - 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'; + // 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; } - } - _performSort(event) { - const header = event.target.closest('.sortable'); - if (!header) return; + // Modified method to handle both formats + generateTable(data = {}) { + if (!this.elements.container) return; - const column = header.dataset.column; - const { currentSort } = this.state; - const newDirection = currentSort.column === column && currentSort.direction === 'asc' ? 'desc' : 'asc'; + // 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) : []; + } - this.state.currentSort = { column, direction: newDirection }; + if (!Object.keys(processedData).length) { + this.elements.container.innerHTML = '

No data available.

'; + return; + } - this.elements.container.querySelectorAll('th.sortable').forEach(th => { - th.setAttribute('aria-sort', 'none'); - th.classList.remove('sorted-asc', 'sorted-desc'); - }); + this.state.currentData = processedData; + this.state.filteredData = { ...processedData }; - header.setAttribute('aria-sort', newDirection === 'asc' ? 'ascending' : 'descending'); - header.classList.add(`sorted-${newDirection}`); + const firstHostKey = Object.keys(processedData)[0]; + const columns = firstHostKey ? Object.keys(processedData[firstHostKey]) : []; + const tableId = this.elements.container.id.replace("-container", ""); - this._sortData(); - this._updateTableContent(); - } + const fragment = document.createDocumentFragment(); + const wrapper = document.createElement("div"); + wrapper.className = "table-wrapper"; - _sortData() { - const { column, direction } = this.state.currentSort; - const entries = Object.entries(this.state.filteredData); + const table = document.createElement("table"); + table.id = tableId; + table.className = "enhanced-table"; + table.setAttribute("aria-label", "Service Comparison Table"); - 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); + const thead = document.createElement("thead"); + thead.innerHTML = this._generateTableHeader(columns); + table.appendChild(thead); - const comparison = valueA > valueB ? 1 : valueA < valueB ? -1 : 0; - return direction === 'asc' ? comparison : -comparison; - }); + const tbody = document.createElement("tbody"); + tbody.innerHTML = this._generateTableRows(this.state.filteredData, columns); + table.appendChild(tbody); - this.state.filteredData = Object.fromEntries(entries); - } + wrapper.appendChild(table); + fragment.appendChild(wrapper); - _updateTableContent() { - 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]) : []; - requestAnimationFrame(() => { - tbody.innerHTML = this._generateTableRows(this.state.filteredData, providers); - }); - } + this.elements.container.innerHTML = ""; + this.elements.container.appendChild(fragment); + + this._attachTableEvents(); + } + + _generateTableHeader(columns) { + return ` + + + Service Name + + + ${columns.map(column => ` + + ${column} + + + `).join("")} + + `; + } + + _generateTableRows(data, columns) { + const rows = []; + for (const [host, hostData] of Object.entries(data)) { + rows.push(` + + +
+ ${host} +
+ + ${columns.map(column => { + const isSupported = hostData[column] === "yes"; + return ` + + + ${isSupported ? + '' : + '' + } + + + `; + }).join("")} + + `); + } + return rows.join(""); + } + + _attachTableEvents() { + if (!this.options.sortable) return; + + const thead = this.elements.container.querySelector("thead"); + if (thead) { + 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: sortableHeader }); + } + }); + } + } + + _performSearch() { + const searchTerm = (this.elements.searchInput?.value || "").toLowerCase().trim(); + + if (searchTerm) { + this.state.filteredData = Object.fromEntries( + Object.entries(this.state.currentData).filter(([host]) => + host.toLowerCase().includes(searchTerm) + ) + ); + 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(searchTerm); + } + + _clearSearch() { + if (this.elements.searchInput) { + this.elements.searchInput.value = ""; + } + this.state.filteredData = { ...this.state.currentData }; + if (this.elements.clearIcon) { + this.elements.clearIcon.style.display = "none"; + } + this._updateTableContent(); + this._updateSearchResults(""); + } + + _updateSearchResults(searchTerm) { + if (!this.elements.container) return; + + let resultsElement = this.elements.container.querySelector(".search-results"); + + if (searchTerm) { + const filteredCount = Object.keys(this.state.filteredData).length; + const totalCount = Object.keys(this.state.currentData).length; + + if (!resultsElement) { + resultsElement = document.createElement("div"); + resultsElement.className = "search-results"; + this.elements.container.insertBefore(resultsElement, this.elements.container.firstChild); + } + + 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 sortableHeader = event.target.closest(".sortable"); + if (!sortableHeader) return; + + const column = sortableHeader.dataset.column; + const { currentSort } = this.state; + const direction = currentSort.column === column && currentSort.direction === "asc" ? "desc" : "asc"; + + this.state.currentSort = { column, direction }; + + // Update UI + this.elements.container.querySelectorAll("th.sortable").forEach(header => { + header.setAttribute("aria-sort", "none"); + header.classList.remove("sorted-asc", "sorted-desc"); + }); + + sortableHeader.setAttribute("aria-sort", direction === "asc" ? "ascending" : "descending"); + sortableHeader.classList.add(`sorted-${direction}`); + + this._sortData(); + this._updateTableContent(); + } + + _sortData() { + const { column, direction } = this.state.currentSort; + const entries = Object.entries(this.state.filteredData); + + entries.sort(([hostA, dataA], [hostB, dataB]) => { + let valueA, valueB; + + 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"); + if (!tbody) return; + + 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, columns); + }); + } } /* ============================== Comparison Manager ============================== */ class ComparisonManager { - constructor(containerId, select1Id, select2Id, data) { - this.elements = { - container: document.getElementById(containerId), - select1: document.getElementById(select1Id), - select2: document.getElementById(select2Id) - }; - this.data = data; - this.isComparing = false; - - this.handleCompare = this._generateCompareTable.bind(this); - this._init(); - } - - _init() { - this._populateDropdowns(); - - this.elements.select1?.addEventListener('change', this.handleCompare); - 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 = '' + - providers.map(p => ``).join(''); - - [this.elements.select1, this.elements.select2].forEach(select => { - if (select) select.innerHTML = optionsHTML; - }); - } - - _generateCompareTable() { - const provider1 = this.elements.select1?.value; - const provider2 = this.elements.select2?.value; - - if (!provider1 || !provider2) { - this._showEmptyState(); - return; - } - if (provider1 === provider2) { - this._showSameProviderWarning(); - return; + constructor(containerId, select1Id, select2Id, data) { + this.elements = { + container: document.getElementById(containerId), + 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(); } - this.isComparing = true; - const loaderId = loadingManager.show(this.elements.container, 'Generating comparison...'); - - requestAnimationFrame(() => { - this._renderComparisonTable(provider1, provider2); - loadingManager.hide(this.elements.container, loaderId); - }); - } - - _renderComparisonTable(provider1, provider2) { - const stats = this._calculateComparisonStats(provider1, provider2); - const fragment = document.createDocumentFragment(); - const wrapper = document.createElement('div'); - - wrapper.innerHTML = ` -
-

Comparing ${provider1} vs ${provider2}

-
-
Shared Support${stats.shared}
-
${provider1} Only${stats.provider1Only}
-
${provider2} Only${stats.provider2Only}
-
-
- -
-
- -
- - - -
- -
- - - - - - - - - - - ${this._generateComparisonRows(provider1, provider2)} - -
Service Name${provider1}${provider2}Status
-
- `; - - fragment.appendChild(wrapper); - this.elements.container.innerHTML = ''; - this.elements.container.appendChild(fragment); - this.elements.container.style.display = 'block'; - this._attachComparisonEvents(); - - requestAnimationFrame(() => { - this.elements.container.classList.add('comparison-visible'); - }); - } - - _generateComparisonRows(provider1, provider2) { - const rows = []; - const checkSvg = ''; - const crossSvg = ''; - - for (const [host, providerData] of Object.entries(this.data)) { - const support1 = providerData[provider1] === 'yes'; - const support2 = providerData[provider2] === 'yes'; - - 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`; } - - rows.push(` - - ${host} - - ${support1 ? checkSvg : crossSvg} - - - ${support2 ? checkSvg : crossSvg} - - ${statusText} - - `); + _init() { + this._populateDropdowns(); + + if (this.elements.select1) { + this.elements.select1.addEventListener("change", this.handleCompare); + } + + if (this.elements.select2) { + this.elements.select2.addEventListener("change", this.handleCompare); + } + + document.addEventListener("keydown", (e) => { + if (this.isComparing && e.key === "Escape") { + this._closeComparison(); + } + }); } - 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++; + _populateDropdowns() { + // Use the stored services array + const optionsHtml = '' + + this.services.map(service => + `` + ).join(""); + + [this.elements.select1, this.elements.select2].forEach(select => { + if (select) { + select.innerHTML = optionsHtml; + } + }); } - return { shared, provider1Only, provider2Only }; - } - _attachComparisonEvents() { - const closeBtn = this.elements.container.querySelector('#close-compare'); - closeBtn?.addEventListener('click', () => this._closeComparison()); - - 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'); - 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++; - }); - - 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); + // 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] || {}; + } } - indicator.textContent = `Showing ${visibleCount} services`; - } - _closeComparison() { - this.elements.container.classList.add('comparison-hiding'); - setTimeout(() => { - this.elements.container.style.display = 'none'; - this.elements.container.classList.remove('comparison-visible', 'comparison-hiding'); - this.isComparing = false; - this._showEmptyState(); - }, 250); - } + _generateCompareTable() { + const service1 = this.elements.select1?.value; + const service2 = this.elements.select2?.value; + + if (!service1 || !service2) { + this._showEmptyState(); + return; + } + + if (service1 === service2) { + this._showSameProviderWarning(); + return; + } + + this.isComparing = true; + const loaderId = loadingManager.show(this.elements.container, "Generating comparison..."); + + requestAnimationFrame(() => { + this._renderComparisonTable(service1, service2); + loadingManager.hide(this.elements.container, loaderId); + }); + } - _showEmptyState() { - this.elements.container.innerHTML = ` -
- -

Ready to Compare

-

Select two services above to see a detailed comparison

-
- `; - this.elements.container.style.display = 'block'; - } + _renderComparisonTable(service1, service2) { + const stats = this._calculateComparisonStats(service1, service2); + + const fragment = document.createDocumentFragment(); + const wrapper = document.createElement("div"); + wrapper.innerHTML = ` +
+

Comparing ${service1} vs ${service2}

+
+
Shared Support${stats.shared}
+
${service1} Only${stats.service1Only}
+
${service2} Only${stats.service2Only}
+
+
+ +
+
- _showSameProviderWarning() { - this.elements.container.innerHTML = ` -
-
⚠️
-

Same Service Selected

-

Please select two different services to compare

-
- `; - this.elements.container.style.display = 'block'; - } +
+ + + +
+ +
+ + + + + + + + + + + ${this._generateComparisonRows(service1, service2)} + +
Service Name${service1}${service2}Status
+
+ `; + + fragment.appendChild(wrapper); + this.elements.container.innerHTML = ""; + this.elements.container.appendChild(fragment); + this.elements.container.style.display = "block"; + + this._attachComparisonEvents(); + + requestAnimationFrame(() => { + this.elements.container.classList.add("comparison-visible"); + }); + } + + _generateComparisonRows(service1, service2) { + const rows = []; + const checkIcon = ''; + const crossIcon = ''; + + // Get all hosts (keys from the original data structure) + 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"; + + 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(` + + ${host} + + + ${supported1 ? checkIcon : crossIcon} + + + + + ${supported2 ? checkIcon : crossIcon} + + + ${statusText} + + `); + } + + return rows.join(""); + } + + _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, service1Only, service2Only }; + } + + _attachComparisonEvents() { + 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._filterComparison(e.target.value); + } + }); + } + + _filterComparison(filterValue) { + const rows = this.elements.container.querySelectorAll(".comparison-row"); + let visibleCount = 0; + + rows.forEach(row => { + const status = row.dataset.status; + 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 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); + } + + resultsElement.textContent = `Showing ${visibleCount} services`; + } + + _closeComparison() { + this.elements.container.classList.add("comparison-hiding"); + setTimeout(() => { + this.elements.container.style.display = "none"; + this.elements.container.classList.remove("comparison-visible", "comparison-hiding"); + this.isComparing = false; + this._showEmptyState(); + }, 250); + } + + _showEmptyState() { + this.elements.container.innerHTML = ` +
+ +

Ready to Compare

+

Select two services above to see a detailed comparison

+
+ `; + this.elements.container.style.display = "block"; + } + + _showSameProviderWarning() { + this.elements.container.innerHTML = ` +
+
⚠️
+

Same Service Selected

+

Please select two different services to compare

+
+ `; + 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 = `

Failed to load data: ${dataUrls[index]}

`; + if (el) el.innerHTML = `

Failed to load data: ${dataUrls[index]}

`; } }); diff --git a/dist/scripts/replace-assets.js b/dist/scripts/replace-assets.js deleted file mode 100644 index 2d17b35..0000000 --- a/dist/scripts/replace-assets.js +++ /dev/null @@ -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'); \ No newline at end of file diff --git a/package.json b/package.json index 455fa7b..611bfe9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/json-optimizer.sh b/scripts/json-optimizer.sh new file mode 100755 index 0000000..ab25304 --- /dev/null +++ b/scripts/json-optimizer.sh @@ -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 [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 \ No newline at end of file