PageSourceSearch

https://localfirstlab.org/tools.js

js localfirstlab.org collected 2026-09-24 15:09:12 UTC 5,897 bytes, 147 lines download raw bytes

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.