1/* 2 * Local First Lab - assets/tools.js: two client-side, zero-network features. 3 * 4 * 1. #savings-calculator (homepage) - tallies checked SaaS subscriptions' 5 * annual cost vs. the self-hosted alternative's cost (0 for genuinely 6 * free software, a small figure for the few "hosted-but-privacy- 7 * respecting" picks like Proton VPN). Pure arithmetic over data-* 8 * attributes already rendered by build.py - no fetch, no persistence 9 * (every box starts unchecked on every load/reload). 10 * 11 * 2. #alt-filters (/alternatives.html) - live search + category/difficulty/ 12 * license filtering over the comparison table. Progressive enhancement: 13 * ships `hidden`; the underlying <table class="matrix"> is fully 14 * populated and usable with JS off - this toolbar adds nothing that's 15 * load-bearing for a no-JS visitor. 16 * 17 * Same conventions as interact.js/reveal.js/demo.js: hand-rolled vanilla 18 * IIFEs, no build step, no framework, zero external requests, zero storage. 19 */ 20 21/* ========================================================================== 22 * Feature 1 - savings calculator 23 * ========================================================================== */ 24(function () { 25 'use strict'; 26 var root = document.getElementById('savings-calculator'); 27 if (!root) return; // not the homepage: nothing to do 28 29 var boxes = root.querySelectorAll('.sc-check'); 30 var elSaas = document.getElementById('sc-total-saas'); 31 var elAlt = document.getElementById('sc-total-alt'); 32 var elSavings = document.getElementById('sc-total-savings'); 33 34 function fmt(n) { return '$' + Math.round(n).toLocaleString('en-US'); } 35 36 function update() { 37 var saas = 0, alt = 0; 38 for (var i = 0; i < boxes.length; i++) { 39 if (!boxes[i].checked) continue;
40 saas += parseFloat(boxes[i].dataset.price) || 0; 41 alt += parseFloat(boxes[i].dataset.altCost) || 0; 42 } 43 if (elSaas) elSaas.textContent = fmt(saas); 44 if (elAlt) elAlt.textContent = fmt(alt); 45 if (elSavings) elSavings.textContent = fmt(saas - alt); 46 } 47 48 root.addEventListener('change', function (e) { 49 if (e.target && e.target.classList && e.target.classList.contains('sc-check')) update(); 50 }); 51})(); 52 53/* ========================================================================== 54 * Feature 2 - alternatives catalog live filter 55 * ========================================================================== */ 56(function () { 57 'use strict'; 58 var filters = document.getElementById('alt-filters'); 59 var table = document.querySelector('table.matrix'); 60 if (!filters || !table) return; // not /alternatives.html: nothing to do 61 62 var rows = table.querySelectorAll('tbody tr'); 63 var search = document.getElementById('alt-search'); 64 var catSelect = document.getElementById('alt-cat-select'); 65 var countEl = document.getElementById('alt-count'); 66 var emptyEl = document.getElementById('alt-empty'); 67 var chips = filters.querySelectorAll('.chip[data-group]'); 68 var clearBtn = document.getElementById('alt-clear'); 69 70 var state = { q: '', diff: 'all', lic: 'all', cat: 'all' }; 71 72 function apply() { 73 var shown = 0, q = state.q.trim().toLowerCase(); 74 for (var i = 0; i < rows.length; i++) { 75 var r = rows[i], match = true; 76 if (q) { 77 var hay = (r.dataset.name || '') + ' ' + (r.dataset.replaces || '') + 78 ' ' + (r.dataset.cat || '').toLowerCase(); 79 if (hay.indexOf(q) === -1) match = false; 80 } 81 if (match && state.diff !== 'all' && r.dataset.diff !== state.diff) match = false; 82 if (match && state.lic !== 'all' && r.dataset.lic !== state.lic) match = false; 83 if (match && state.cat !== 'all' && r.dataset.cat !== state.cat) match = false; 84 r.hidden = !match; 85 if (match) shown++; 86 } 87 if (countEl) countEl.textContent = 'Showing ' + shown + ' of ' + rows.length; 88 if (emptyEl) emptyEl.hidden = shown !== 0; 89 } 90 91 var debounceHandle; 92 if (search) { 93 search.addEventListener('input', function () { 94 state.q = search.value; 95 clearTimeout(debounceHandle); 96 debounceHandle = setTimeout(apply, 150); 97 }); 98 } 99 100 if (catSelect) { 101 catSelect.addEventListener('change', function () { 102 state.cat = catSelect.value; 103 apply(); 104 }); 105 } 106 107 for (var i = 0; i < chips.length; i++) { 108 chips[i].addEventListener('click', function () { 109 var group = this.dataset.group, val = this.dataset.filter; 110 state[group] = val; 111 var siblings = filters.querySelectorAll('.chip[data-group="' + group + '"]'); 112 for (var j = 0; j < siblings.length; j++) { 113 siblings[j].classList.remove('is-active'); 114 siblings[j].setAttribute('aria-pressed', 'false'); 115 } 116 this.classList.add('is-active'); 117 this.setAttribute('aria-pressed', 'true'); 118 apply(); 119 }); 120 } 121 122 if (clearBtn) { 123 clearBtn.addEventListener('click', function () { 124 state = { q: '', diff: 'all', lic: 'all', cat: 'all' }; 125 if (search) search.value = ''; 126 if (catSelect) catSelect.value = 'all'; 127 var allChips = filters.querySelectorAll('.chip'); 128 for (var i = 0; i < allChips.length; i++) { 129 var isAll = allChips[i].dataset.filter === 'all'; 130 allChips[i].classList.toggle('is-active', isAll); 131 allChips[i].setAttribute('aria-pressed', isAll ? 'true' : 'false'); 132 } 133 apply(); 134 }); 135 } 136 137 // deep-link support: /alternatives.html?q=photos (the terminal's `search` 138 // command lands here). Read-only parse of the current URL; nothing stored, 139 // nothing sent, and typing in the box afterwards works exactly as before. 140 try { 141 var qp = new URLSearchParams(window.location.search).get('q'); 142 if (qp && search) { search.value = qp; state.q = qp; } 143 } catch (e) { /* very old engines: the box still works, just no deep link */ } 144 145 apply(); 146 filters.hidden = false; // JS is alive: reveal the toolbar 147})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.