PageSourceSearch

https://www.boweryboyshistory.com/wp-content/plugins/bowery-episode-index/assets/episode-index.js?ver=1.6.5

js boweryboyshistory.com collected 2026-09-24 08:49:44 UTC 13,144 bytes, 350 lines download raw bytes

1/* Bowery Boys Episode Index — v1.5 */
2(function () {
3  'use strict';
4
5  if (document.readyState === 'loading') {
6    document.addEventListener('DOMContentLoaded', init);
7  } else {
8    init();
9  }
10
11  function init() {
12    var grid = document.getElementById('bb-ei-grid');
13    if (!grid) return;
14
15    var cfg       = window.bbEI || {};
16    var batchSize = parseInt(cfg.batch, 10) || 25;
17
18    var searchEl     = document.getElementById('bb-ei-search');
19    var catEl        = document.getElementById('bb-ei-cat');
20    var yearEl       = document.getElementById('bb-ei-year');
21    var clearBtn     = document.getElementById('bb-ei-clear');
22    var clearBtn2    = document.getElementById('bb-ei-clear-2');
23    var countNum     = document.getElementById('bb-ei-count-num');
24    var emptyState   = document.getElementById('bb-ei-empty');
25    var loadMoreWrap = document.getElementById('bb-ei-loadmore');
26    var loadMoreBtn  = document.getElementById('bb-ei-loadmore-btn');
27    var remainingEl  = document.getElementById('bb-ei-remaining');
28    var viewGridBtn  = document.getElementById('bb-ei-view-grid');
29    var viewListBtn  = document.getElementById('bb-ei-view-list');
30
31    var cards = Array.prototype.slice.call(grid.querySelectorAll('.bb-ei-card'));
32    var total = cards.length;
33
34    // Build Fuse.js search index from card data attributes.
35    // Two separate instances so title and description/tag fields can have
36    // different thresholds — Fuse 6.x only supports a global threshold.
37    var fuseIndex = cards.map(function (card) {
38      return {
39        el:          card,
40        title:       card.dataset.title       || '',
41        description: card.dataset.description || '',
42        tags:        card.dataset.tags        || '',
43        date:        card.dataset.date        || ''
44      };
45    });
46    var fuseShared = {
47      includeScore:       true,  // r.score must be present for intersection logic
48      ignoreLocation:     true,  // match keyword anywhere in the string
49      minMatchCharLength: 2
50    };
51    // Title: allows ~1 character of variation (typos, plural 's', etc.)
52    var fuseTitle = new Fuse(fuseIndex, Object.assign({}, fuseShared, {
53      keys:      ['title'],
54      threshold: 0.2
55    }));
56    // Description + tags: near-exact word presence only.
57    var fuseBody = new Fuse(fuseIndex, Object.assign({}, fuseShared, {
58      keys:      ['description', 'tags'],
59      threshold: 0.1
60    }));
61    // Date: zero fuzziness — Bitap with 0 errors is exact substring match.
62    // 'july' matches 'july 2019' but not 'june 2019'.
63    var fuseDate = new Fuse(fuseIndex, Object.assign({}, fuseShared, {
64      keys:      ['date'],
65      threshold: 0
66    }));
67
68    // Count how many cards PHP rendered as initially visible (no hidden attr).
69    var loadedCount = 0;
70    for (var i = 0; i < total; i++) {
71      if (!cards[i].hidden) loadedCount++;
72    }
73    // loadedCount === batchSize on a fresh page load.
74
75    var isFiltering = false;
76    var debounceTimer;
77
78    // -------------------------------------------------------------------------
79    // Core: show/hide helpers
80    // -------------------------------------------------------------------------
81
82    /** Re-insert all cards into the grid in their original DOM order. */
83    function restoreOrder() {
84      for (var i = 0; i < total; i++) {
85        grid.appendChild(cards[i]);
86      }
87    }
88
89    /** Restore the grid to its "loaded up to loadedCount" state (no filter). */
90    function restoreToLoaded() {
91      restoreOrder();
92      for (var i = 0; i < total; i++) {
93        cards[i].hidden = (i >= loadedCount);
94      }
95    }
96
97    // -------------------------------------------------------------------------
98    // Filtering — the [hidden] attribute is the single source of truth.
99    // No CSS class toggling, no specificity fights.
100    // -------------------------------------------------------------------------
101
102    // Each filter clears the other two before applying, so every filter action
103    // always runs against the full episode list.
104    function onInput() {
105      clearTimeout(debounceTimer);
106      catEl.value  = '';
107      yearEl.value = '';
108      catEl.classList.remove('bb-active');
109      yearEl.classList.remove('bb-active');
110      debounceTimer = setTimeout(applyFilters, 130);
111    }
112
113    function onCatChange() {
114      clearTimeout(debounceTimer);
115      searchEl.value = '';
116      yearEl.value   = '';
117      yearEl.classList.remove('bb-active');
118      applyFilters();
119    }
120
121    function onYearChange() {
122      clearTimeout(debounceTimer);
123      searchEl.value = '';
124      catEl.value    = '';
125      catEl.classList.remove('bb-active');
126      applyFilters();
127    }
128
129    function applyFilters() {
130      var q    = searchEl.value.toLowerCase().trim();
131      var cat  = catEl.value;
132      var year = yearEl.value;
133
134      isFiltering = !!(q || cat || year);
135
136      clearBtn.hidden = !isFiltering;
137      catEl.classList.toggle('bb-active', !!cat);
138      yearEl.classList.toggle('bb-active', !!year);
139
140      if (isFiltering) {
141        // Fuzzy search: per-token score Maps, AND-intersected, with phrase boost.
142        // matchMap: Map<el, combinedScore> — null means no text search active.
143        var matchMap = null;
144        if (q) {
145          var tokens = q.split(/\s+/).filter(Boolean);
146
147          // For each token, build Map<el, bestScore> across title, body, and date.
148          var tokenMaps = tokens.map(function (token) {
149            var m = new Map();
150            fuseTitle.search(token).forEach(function (r) {
151              m.set(r.item.el, r.score);
152            });
153            fuseBody.search(token).forEach(function (r) {
154              var prev = m.get(r.item.el);
155              if (prev === undefined || r.score < prev) m.set(r.item.el, r.score);
156            });
157            fuseDate.search(token).forEach(function (r) {
158              var prev = m.get(r.item.el);
159              if (prev === undefined || r.score < prev) m.set(r.item.el, r.score);
160            });
161            return m;
162          });
163
164          // Debug: log per-token match counts so we can see where results drop.
165          tokens.forEach(function (token, idx) {
166            console.log('[bbEI] token "' + token + '": ' + tokenMaps[idx].size + ' matches');
167          });
168
169          // AND intersection: an episode must match every token.
170          // Combined score = sum of per-token best scores (lower = more relevant).
171          matchMap = new Map();
172          tokenMaps[0].forEach(function (score, el) {
173            var combined = score;
174            for (var t = 1; t < tokenMaps.length; t++) {
175              var s = tokenMaps[t].get(el);
176              if (s === undefined) { combined = null; return; }
177              combined += s;
178            }
179            if (combined !== null) matchMap.set(el, combined);
180          });
181
182          console.log('[bbEI] after AND intersection: ' + matchMap.size + ' matches');
183
184          // Phrase boost: if the full multi-word query matches as a phrase,
185          // multiply that episode's combined score by a fraction so it floats
186          // above episodes that merely contain all tokens separately.
187          if (tokens.length > 1) {
188            fuseTitle.search(q).forEach(function (r) {
189              if (matchMap.has(r.item.el)) {
190                matchMap.set(r.item.el, matchMap.get(r.item.el) * 0.25);
191              }
192            });
193            fuseBody.search(q).forEach(function (r) {
194              if (matchMap.has(r.item.el)) {
195                matchMap.set(r.item.el, matchMap.get(r.item.el) * 0.4);
196              }
197            });
198          }
199        }
200
201        // Evaluate ALL cards and collect the visible ones with their scores.
202        var visibleCards = [];
203        for (var i = 0; i < total; i++) {
204          var d = cards[i].dataset;
205          var show =
206            (!q    || matchMap.has(cards[i])) &&
207            (!cat  || (' ' + d.cats + ' ').indexOf(' ' + cat + ' ') !== -1) &&
208            (!year || d.year === year);
209
210          cards[i].hidden = !show;
211          if (show) visibleCards.push(cards[i]);
212        }
213
214        // When a text search is active, re-order visible cards by relevance score.
215        // appendChild moves each element to the end of the grid in sorted order;
216        // hidden cards stay in place and are invisible, so order doesn't matter.
217        if (q && visibleCards.length > 0) {
218          visibleCards.sort(function (a, b) {
219            return matchMap.get(a) - matchMap.get(b);
220          });
221          visibleCards.forEach(function (el) { grid.appendChild(el); });
222        }
223
224        countNum.textContent = visibleCards.length;
225        emptyState.hidden    = visibleCards.length > 0;
226        if (loadMoreWrap) loadMoreWrap.hidden = true;
227
228      } else {
229        // No filter — snap back to the loaded batch.
230        restoreToLoaded();
231        countNum.textContent = total;
232        emptyState.hidden    = true;
233        updateLoadMore();
234      }
235    }
236
237    // -------------------------------------------------------------------------
238    // Load More
239    // -------------------------------------------------------------------------
240
241    function updateLoadMore() {
242      if (!loadMoreWrap) return;
243      var left = total - loadedCount;
244      loadMoreWrap.hidden = (left <= 0);
245      if (remainingEl && left > 0) {
246        remainingEl.textContent = left + ' not yet shown';
247      }
248    }
249
250    function loadMore() {
251      var end = Math.min(loadedCount + batchSize, total);
252      for (var i = loadedCount; i < end; i++) {
253        cards[i].hidden = false;
254      }
255      loadedCount = end;
256
257      // Scroll the first newly-revealed card into view.
258      if (cards[loadedCount - batchSize]) {
259        cards[loadedCount - batchSize].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
260      }
261      updateLoadMore();
262    }
263
264    // -------------------------------------------------------------------------
265    // Clear
266    // -------------------------------------------------------------------------
267
268    function clearFilters() {
269      searchEl.value = '';
270      catEl.value    = '';
271      yearEl.value   = '';
272      catEl.classList.remove('bb-active');
273      yearEl.classList.remove('bb-active');
274      isFiltering = false;
275      restoreToLoaded();
276      countNum.textContent = total;
277      emptyState.hidden    = true;
278      updateLoadMore();
279      searchEl.focus();
280    }
281
282    // -------------------------------------------------------------------------
283    // URL param sync — shareable filtered links: ?topic=neighborhoods&year=2020
284    // -------------------------------------------------------------------------
285
286    function syncFromURL() {
287      var p = new URLSearchParams(window.location.search);
288      if (p.get('topic')) catEl.value    = p.get('topic');
289      if (p.get('year'))  yearEl.value   = p.get('year');
290      if (p.get('q'))     searchEl.value = p.get('q');
291      if (p.get('topic') || p.get('year') || p.get('q')) applyFilters();
292    }
293
294    // -------------------------------------------------------------------------
295    // View toggle — grid / list
296    // -------------------------------------------------------------------------
297
298    var VIEW_KEY = 'bb-ei-view';
299
300    function setView(view) {
301      if (view === 'list') {
302        grid.classList.add('bb-ei-list');
303        if (viewGridBtn) { viewGridBtn.setAttribute('aria-pressed', 'false'); }
304        if (viewListBtn) { viewListBtn.setAttribute('aria-pressed', 'true');  }
305      } else {
306        grid.classList.remove('bb-ei-list');
307        if (viewGridBtn) { viewGridBtn.setAttribute('aria-pressed', 'true');  }
308        if (viewListBtn) { viewListBtn.setAttribute('aria-pressed', 'false'); }
309      }
310      try { localStorage.setItem(VIEW_KEY, view); } catch (e) {}
311    }
312
313    function restoreView() {
314      var saved;
315      try { saved = localStorage.getItem(VIEW_KEY); } catch (e) {}
316      setView(saved === 'list' ? 'list' : 'grid');
317    }
318
319    // -------------------------------------------------------------------------
320    // Wire up
321    // -------------------------------------------------------------------------
322
323    searchEl.addEventListener('input',  onInput);
324    catEl.addEventListener('change',    onCatChange);
325    yearEl.addEventListener('change',   onYearChange);
326    clearBtn.addEventListener('click',  clearFilters);
327    if (clearBtn2)   clearBtn2.addEventListener('click',   clearFilters);
328    if (loadMoreBtn) loadMoreBtn.addEventListener('click', loadMore);
329    if (viewGridBtn) viewGridBtn.addEventListener('click', function () { setView('grid'); });
330    if (viewListBtn) viewListBtn.addEventListener('click', function () { setView('list'); });
331
332    // Press "/" anywhere to focus the search box (like GitHub/YouTube).
333    document.addEventListener('keydown', function (e) {
334      if (
335        e.key === '/' &&
336        document.activeElement.tagName !== 'INPUT' &&
337        document.activeElement.tagName !== 'TEXTAREA' &&
338        document.activeElement.tagName !== 'SELECT'
339      ) {
340        e.preventDefault();
341        searchEl.focus();
342        searchEl.select();
343      }
344    });
345
346    updateLoadMore();
347    restoreView();
348    syncFromURL();
349  }
350})();

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.