PageSourceSearch

https://theconnectcurrent.com/js/render.js

js theconnectcurrent.com collected 2026-10-07 17:43:13 UTC 32,082 bytes, 670 lines download raw bytes

1/* ============================================================
2   render.js — turns parsed feed data into DOM.
3   Runs after rss-engine.js and router.js (script order matters).
4   ============================================================ */
5
6(function () {
7  'use strict';
8
9  /* ============================================================
10     ★ FEATURED EPISODES CONFIG ★
11     To feature specific episodes in the "New to The Connect
12     Current?" section, paste their GUIDs between the brackets:
13
14       var FEATURED_GUIDS = [
15         '019f2459-1730-73ed-b69e-dbddb046385a',
16         'another-guid-here'
17       ];
18
19     (An episode's GUID is visible in its page URL after "?id=".)
20     If this list is EMPTY, the site automatically features the
21     2nd and 3rd most recent episodes — zero maintenance.
22     ============================================================ */
23  var FEATURED_GUIDS = [];
24
25  /* ============================================================
26     ★ AUTO-CATEGORIZATION ★
27     Episodes are tagged automatically by matching these keywords
28     against each episode's title + summary (whole words only).
29     To tune a category, add/remove keywords. To add a category,
30     add a new line — the filter bar updates itself.
31     ============================================================ */
32  var CATEGORY_RULES = {
33    'Life Science': ['life science', 'biotech', 'biotechnology', 'biology', 'biolabs', 'biosciences', 'biomedical', 'clinical', 'disease', 'therapies', 'therapy', 'pharma', 'drug', 'microscope', 'microscopy', 'fibrotic', 'lab'],
34    'Tech': ['tech', 'technology', 'software', 'hardware', 'robotics', 'bionics', 'engineering', 'digital', 'dual-use', 'cyber', 'data'],
35    'AI': ['ai', 'artificial intelligence', 'machine learning'],
36    'Defense': ['defense', 'defence', 'national security', 'navy', 'military', 'dual-use', 'veteran', 'darpa', 'sbir', 'warfighter', 'maritime'],
37    'Ecosystem': ['ecosystem', 'community', 'capital', 'investing', 'investment', 'investors', 'foundation', 'philanthropic', 'economy', 'nonprofit'],
38    'Leadership': ['leadership', 'ceo', 'founder', 'co-founder', 'executive', 'director', 'vision', 'career']
39  };
40
41  var categoryMatchers = {};
42  Object.keys(CATEGORY_RULES).forEach(function (name) {
43    categoryMatchers[name] = new RegExp('\\b(?:' + CATEGORY_RULES[name].map(function (kw) {
44      return kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
45    }).join('|') + ')\\b', 'i');
46  });
47
48  // Returns the auto-detected categories for an episode (cached)
49  function categoriesOf(ep) {
50    if (!ep._cats) {
51      var haystack = ep.title + ' ' + stripHtml(ep.summary);
52      ep._cats = Object.keys(CATEGORY_RULES).filter(function (name) {
53        return categoryMatchers[name].test(haystack);
54      });
55    }
56    return ep._cats;
57  }
58
59  var EPISODES_PER_PAGE_FIRST = 8;  // shown on initial load
60  var EPISODES_PER_PAGE_MORE = 10;  // added per "Load more" click on the episodes page
61
62  // Where to listen — used by the sidebar list and episode-page icon row.
63  // Kept in sync with about.html, index.html, and listen.html.
64  var PLATFORMS = [
65    { key: 'applepodcasts', name: 'Apple Podcasts', url: 'https://podcasts.apple.com/us/podcast/id1893443530?mt=2&ls=1' },
66    { key: 'spotify',       name: 'Spotify',        url: 'https://open.spotify.com/show/5i8cm9XSnrBlD4XIzwHu92' },
67    { key: 'amazonmusic',   name: 'Amazon Music',   url: 'https://music.amazon.com/podcasts/e5f49c16-eb0a-426f-bc12-c9d0c5b1230f/the-connect-current' },
68    { key: 'iheartradio',   name: 'iHeartRadio',    url: 'https://www.iheart.com/podcast/269-the-connect-current-330247486/' },
69    { key: 'youtube',       name: 'YouTube',        url: 'https://www.youtube.com/@connectinsd' },
70    { key: 'rss',           name: 'RSS Feed',       url: 'https://feeds.resonaterecordings.com/theconnectcurrent' }
71  ];
72
73  /* ---------- Safety helpers ---------- */
74
75  // For plain text interpolated into HTML strings
76  function esc(text) {
77    var div = document.createElement('div');
78    div.textContent = text == null ? '' : String(text);
79    return div.innerHTML;
80  }
81
82  // RSS descriptions contain HTML. Never hand feed HTML to innerHTML
83  // without passing it through here first: strips script/style/embed
84  // elements, event-handler attributes, and javascript: URLs.
85  function sanitizeHtml(html) {
86    var template = document.createElement('template');
87    template.innerHTML = html == null ? '' : String(html);
88    var banned = template.content.querySelectorAll('script, style, iframe, object, embed, form, link, meta');
89    banned.forEach(function (el) { el.remove(); });
90    template.content.querySelectorAll('*').forEach(function (el) {
91      for (var i = el.attributes.length - 1; i >= 0; i--) {
92        var attr = el.attributes[i];
93        var name = attr.name.toLowerCase();
94        var value = String(attr.value).replace(/\s+/g, '').toLowerCase();
95        if (name.indexOf('on') === 0) el.removeAttribute(attr.name);
96        else if ((name === 'href' || name === 'src') && value.indexOf('javascript:') === 0) {
97          el.removeAttribute(attr.name);
98        }
99      }
100      // External links from show notes open in a new tab, safely
101      if (el.tagName === 'A' && el.getAttribute('href')) {
102        el.setAttribute('target', '_blank');
103        el.setAttribute('rel', 'noopener noreferrer');
104      }
105    });
106    return template.innerHTML;
107  }
108
109  function stripHtml(html) {
110    var div = document.createElement('div');
111    div.innerHTML = sanitizeHtml(html);
112    return (div.textContent || '').replace(/\s+/g, ' ').trim();
113  }
114
115  // First sentence only, tightly capped so cards stay simple
116  function previewText(html) {
117    var text = stripHtml(html);
118    var match = text.match(/^.*?[.!?]/);
119    var preview = match ? match[0] : text;
120    if (preview.length > 150) preview = preview.slice(0, 147).replace(/\s+\S*$/, '') + '…';
121    return preview;
122  }
123
124  function formatDate(date) {
125    if (!date || isNaN(date.getTime())) return '';
126    return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
127  }
128
129  /* ---------- Shared building blocks ---------- */
130
131  function episodeCard(ep) {
132    var tags = categoriesOf(ep).slice(0, 1).map(function (t) {
133      return '<span class="episode-card__tag">' + esc(t) + '</span>';
134    }).join('');
135    return '' +
136      '<article class="episode-card">' +
137        '<a class="episode-card__image-link" href="' + esc(TCC.episodeUrl(ep.guid)) + '" tabindex="-1" aria-hidden="true">' +
138          '<img class="episode-card__image" src="' + esc(ep.imageUrl) + '" alt="" loading="lazy" width="160" height="160">' +
139        '</a>' +
140        '<div class="episode-card__body">' +
141          '<p class="episode-card__meta mono">' +
142            esc(formatDate(ep.pubDate)) +
143            (ep.duration ? ' · ' + esc(ep.duration) : ' · Listen') +
144            (tags ? ' ' + tags : '') +
145          '</p>' +
146          '<h3 class="episode-card__title">' +
147            '<a href="' + esc(TCC.episodeUrl(ep.guid)) + '">' + esc(ep.title) + '</a>' +
148          '</h3>' +
149          '<p class="episode-card__preview">' + esc(previewText(ep.description)) + '</p>' +
150        '</div>' +
151      '</article>';
152  }
153
154  function featuredCard(ep) {
155    return '' +
156      '<a class="featured-card" href="' + esc(TCC.episodeUrl(ep.guid)) + '">' +
157        '<img class="featured-card__image" src="' + esc(ep.imageUrl) + '" alt="" loading="lazy" width="280" height="280">' +
158        '<h3 class="featured-card__title">' + esc(ep.title) + '</h3>' +
159        '<p class="featured-card__meta mono">' + esc(formatDate(ep.pubDate)) + '</p>' +
160      '</a>';
161  }
162
163  // Apple-style share row: native share sheet where supported, otherwise
164  // per-network links plus a copy-link button
165  function shareRow(ep) {
166    var url = window.location.href;
167    var text = ep.title + ' — The Connect Current';
168    var ICON = {
169      share: '<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15V3"/><path d="m8 7 4-4 4 4"/><path d="M4 13v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6"/></svg>',
170      x: '<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M18.9 2H22l-7 8 8.2 12h-6.4l-5-7.3L5.9 22H2.8l7.5-8.6L2.4 2h6.6l4.5 6.7L18.9 2zm-1.1 18h1.8L7.3 3.9H5.4L17.8 20z"/></svg>',
171      li: '<svg viewBox="0 0 24 24" width="17" height="17" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-1 1.83-2.05 3.77-2.05 4.03 0 4.78 2.65 4.78 6.1V21h-4v-5.4c0-1.3 0-3-1.8-3s-2.1 1.4-2.1 2.9V21H9z"/></svg>',
172      link: '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg>'
173    };
174    return '<div class="share">' +
175      '<span class="share__label">Share</span>' +
176      (navigator.share ? '<button class="share__btn" type="button" data-share="native" aria-label="Share this episode">' + ICON.share + '</button>' : '') +
177      '<a class="share__btn" href="https://twitter.com/intent/tweet?text=' + encodeURIComponent(text) + '&url=' + encodeURIComponent(url) + '" target="_blank" rel="noopener noreferrer" aria-label="Share on X">' + ICON.x + '</a>' +
178      '<a class="share__btn" href="https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(url) + '" target="_blank" rel="noopener noreferrer" aria-label="Share on LinkedIn">' + ICON.li + '</a>' +
179      '<button class="share__btn" type="button" data-share="copy" aria-label="Copy link to this episode">' + ICON.link + '</button>' +
180    '</div>';
181  }
182
183  function newsletterCard() {
184    // Real Connect Mailchimp endpoint. Field names + honeypot must match
185    // the Mailchimp audience exactly.
186    return '<div class="sidebar__card">' +
187      '<h2>Join the Newsletter</h2>' +
188      '<p class="sidebar__card__lead">Get new episodes and San Diego innovation stories in your inbox, from Connect\'s monthly newsletter.</p>' +
189      '<form class="email-form" action="https://connect.us11.list-manage.com/subscribe/post?u=2bf3d5ba6f5d8cad9ce9df522&amp;id=20da6b5bb6" method="post" target="_blank" rel="noopener noreferrer" novalidate>' +
190        '<div class="email-form__row">' +
191          '<div><label for="ep-fname">First name</label>' +
192            '<input id="ep-fname" name="FNAME" type="text" autocomplete="given-name" placeholder="First name"></div>' +
193          '<div><label for="ep-lname">Last name</label>' +
194            '<input id="ep-lname" name="LNAME" type="text" autocomplete="family-name" placeholder="Last name"></div>' +
195        '</div>' +
196        '<label for="ep-email">Email</label>' +
197        '<input id="ep-email" name="EMAIL" type="email" autocomplete="email" placeholder="Email address" required>' +
198        '<fieldset class="email-form__gdpr">' +
199          '<legend>What would you like to hear about?</legend>' +
200          '<label><input type="checkbox" name="gdpr[13]" value="Y" checked> All Connect Communication</label>' +
201          '<label><input type="checkbox" name="gdpr[17]" value="Y" checked> The Connection — Monthly Newsletter</label>' +
202          '<label><input type="checkbox" name="gdpr[21]" value="Y"> Connect Events</label>' +
203          '<label><input type="checkbox" name="gdpr[22]" value="Y"> Content for Entrepreneurs</label>' +
204          '<label><input type="checkbox" name="gdpr[26]" value="Y"> San Diego Company Spotlights</label>' +
205        '</fieldset>' +
206        '<div aria-hidden="true" style="position:absolute;
206left:-5000px;">' +
207          '<input type="text" name="b_2bf3d5ba6f5d8cad9ce9df522_20da6b5bb6" tabindex="-1" value="">' +
208        '</div>' +
209        '<button class="btn btn--primary" type="submit" name="subscribe">Subscribe</button>' +
210        '<p class="email-form__note">Powered by Mailchimp. Unsubscribe anytime from the footer of any email.</p>' +
211      '</form>' +
212    '</div>';
213  }
214
215  function showUnavailable(container, message) {
216    if (!container) return;
217    container.innerHTML =
218      '<div class="feed-unavailable">' +
219        '<p>' + esc(message || 'Episodes are temporarily unavailable.') + '</p>' +
220        '<p class="feed-unavailable__hint">Please check back soon — new episodes every Tuesday.</p>' +
221      '</div>';
222  }
223
224  /* ---------- Homepage ---------- */
225
226  function renderHome(data) {
227    var channel = data.channel;
228    var episodes = data.episodes;
229    var latest = episodes[0];
230
231    // Hero: description pulled from the feed (first paragraph), not hardcoded
232    var heroDesc = document.getElementById('hero-description');
233    if (heroDesc && channel.description) {
234      heroDesc.textContent = channel.description.split(/\n\s*\n/)[0];
235    }
236
237    // Hero: latest episode in the custom player
238    var heroPlayer = document.getElementById('hero-player');
239    if (heroPlayer && latest) {
240      heroPlayer.innerHTML =
241        '<p class="hero-player__label mono">Latest episode · ' + esc(formatDate(latest.pubDate)) + '</p>' +
242        '<h2 class="hero-player__title">' +
243          '<a href="' + esc(TCC.episodeUrl(latest.guid)) + '">' + esc(latest.title) + '</a>' +
244        '</h2>' +
245        '<div class="player-mount"></div>';
246      TCC.createPlayer(heroPlayer.querySelector('.player-mount'), latest.audioUrl, latest.title,
247        { image: latest.imageUrl, url: TCC.episodeUrl(latest.guid) });
248    }
249
250    // Featured: hardcoded GUIDs win; otherwise the show trailer plus the
251    // most recent episode after the hero's
252    var featuredWrap = document.getElementById('featured-episodes');
253    if (featuredWrap) {
254      var featured = [];
255      if (FEATURED_GUIDS.length) {
256        featured = FEATURED_GUIDS.map(function (guid) {
257          return episodes.find(function (ep) { return ep.guid === guid; });
258        }).filter(Boolean);
259      }
260      if (!featured.length) {
261        var trailer = episodes.find(function (ep) { return ep.episodeType === 'trailer'; }) ||
262                      episodes.find(function (ep) { return /trailer/i.test(ep.title); });
263        featured = trailer ? [trailer] : [];
264        // Second slot: the Tre Braquet episode (Connect 3.0), falling back
265        // to the newest episode not already shown
266        var second = episodes.find(function (ep) {
267          return ep !== trailer && /braquet/i.test(ep.title + ' ' + ep.description);
268        }) || episodes.slice(1).find(function (ep) { return ep !== trailer; });
269        if (second) featured.push(second);
270        if (!featured.length) featured = episodes.slice(1, 3);
271      }
272      featuredWrap.innerHTML = featured.map(featuredCard).join('');
273    }
274
275    // ---- Filtering: search + category + quarter + date sort ----
276    // The feed has no per-episode categories, so category filtering is a
277    // keyword match against each episode's title + summary.
278    var chipsWrap = document.getElementById('category-chips');
279    var grid = document.getElementById('episode-grid');
280    var moreBtn = document.getElementById('view-all-btn');
281    var isEpisodesPage = document.body.getAttribute('data-page') === 'episodes';
282    var shownCount = EPISODES_PER_PAGE_FIRST;
283    var state = { query: '', category: null, quarter: null, sort: 'newest' };
284
285    var categoryNames = Object.keys(CATEGORY_RULES);
286
287    function matchesCategory(ep, name) {
288      return categoriesOf(ep).indexOf(name) !== -1;
289    }
290    function quarterOf(ep) {
291      var d = ep.pubDate;
292      if (!d || isNaN(d.getTime())) return '';
293      return 'Q' + (Math.floor(d.getMonth() / 3) + 1) + ' ' + d.getFullYear();
294    }
295    function visibleEpisodes() {
296      var list = episodes.filter(function (ep) {
297        if (state.category && !matchesCategory(ep, state.category)) return false;
298        if (state.quarter && quarterOf(ep) !== state.quarter) return false;
299        if (state.query) {
300          var hay = (ep.title + ' ' + stripHtml(ep.description)).toLowerCase();
301          if (hay.indexOf(state.query.toLowerCase()) === -1) return false;
302        }
303        return true;
304      });
305      if (state.sort === 'oldest') list = list.slice().reverse();
306      return list;
307    }
308    function drawGrid() {
309      var list = visibleEpisodes();
310      if (!list.length) {
311        grid.innerHTML =
312          '<div class="feed-unavailable"><p>No episodes match those filters.</p>' +
313          '<p class="feed-unavailable__hint">Try a different search or reset the filters.</p></div>';
314        if (moreBtn) moreBtn.hidden = true;
315        return;
316      }
317      grid.innerHTML = list.slice(0, shownCount).map(episodeCard).join('');
318      // On home the button is a permanent link to the episodes page. On the
319      // episodes page it pages in more, then becomes a collapse control once
320      // everything is on screen — and goes away entirely when the whole list
321      // fitted on the first page, since there'd be nothing to collapse.
322      if (moreBtn && isEpisodesPage) {
323        var allShown = shownCount >= list.length;
324        var expanded = shownCount > EPISODES_PER_PAGE_FIRST;
325        moreBtn.hidden = allShown && !expanded;
326        moreBtn.textContent = allShown ? 'Show less' : 'Load more';
327        moreBtn.setAttribute('data-mode', allShown ? 'collapse' : 'more');
328      }
329    }
330    function resetPagingAndDraw() { shownCount = EPISODES_PER_PAGE_FIRST; drawGrid(); }
331
332    // Segmented category control + filter panel (sort / category / quarter)
333    var seg, thumb;
334
335    function moveThumb() {
336      if (!seg) return;
337      var active = seg.querySelector('.segmented__btn.is-active');
338      if (!active) return;
339      thumb.style.width = active.offsetWidth + 'px';
340      thumb.style.transform = 'translateX(' + active.offsetLeft + 'px)';
341    }
342
343    function setCategory(name) {
344      state.category = (name === 'All') ? null : name;
345      if (seg) {
346        seg.querySelectorAll('.segmented__btn').forEach(function (b) {
347          var on = b.textContent === (state.category || 'All');
348          b.classList.toggle('is-active', on);
349          b.setAttribute('aria-pressed', String(on));
350        });
351        moveThumb();
352      }
353      syncPanel();
354      resetPagingAndDraw();
355    }
356
357    var panel = null;
358    function syncPanel() {
359      if (!panel) return;
360      panel.querySelectorAll('.filter__opt').forEach(function (b) {
361        var group = b.getAttribute('data-group');
362        var value = b.getAttribute('data-value');
363        var on = (group === 'sort' && value === state.sort) ||
364                 (group === 'category' && value === (state.category || 'All')) ||
365                 (group === 'quarter' && value === (state.quarter || 'All'));
366        b.classList.toggle('is-active', on);
367        b.setAttribute('aria-pressed', String(on));
368      });
369    }
370
371    if (chipsWrap) {
372      var quarters = [];
373      episodes.forEach(function (ep) {
374        var q = quarterOf(ep);
375        if (q && quarters.indexOf(q) === -1) quarters.push(q);
376      });
377
378      function optBtns(group, values) {
379        return values.map(function (v) {
380          return '<button class="filter__opt" type="button" data-group="' + group + '" data-value="' + esc(v) + '" aria-pressed="false">' + esc(v === 'newest' ? 'Newest first' : v === 'oldest' ? 'Oldest first' : v) + '</button>';
381        }).join('');
382      }
383
384      chipsWrap.innerHTML =
385        '<div class="filter">' +
386          '<button class="filter__btn" type="button" aria-expanded="false" aria-label="More filters">' +
387            // Funnel (cone) icon, Google-Sheets style
388            '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M3.4 4.6A1 1 0 0 1 4.3 4h15.4a1 1 0 0 1 .77 1.64l-5.87 7.04a1 1 0 0 0-.23.64v4.9a1 1 0 0 1-.55.9l-3 1.5A1 1 0 0 1 9.4 19.6v-6.28a1 1 0 0 0-.23-.64L3.3 5.64a1 1 0 0 1-.1-1.04z"/></svg>' +
389          '</button>' +
390          '<div class="filter__panel" hidden>' +
391            '<div class="filter__group"><p class="filter__label">Sort</p><div class="filter__opts">' + optBtns('sort', ['newest', 'oldest']) + '</div></div>' +
392            '<div class="filter__group"><p class="filter__label">Category</p><div class="filter__opts">' + optBtns('category', ['All'].concat(categoryNames)) + '</div></div>' +
393            '<div class="filter__group"><p class="filter__label">Quarter</p><div class="filter__opts">' + optBtns('quarter', ['All'].concat(quarters)) + '</div></div>' +
394            '<button class="filter__clear" type="button">Reset all filters</button>' +
395          '</div>' +
396        '</div>' +
397        '<div class="seg-scroll">' +
398          '<div class="segmented" role="group" aria-label="Filter episodes by topic">' +
399            '<span class="segmented__thumb" aria-hidden="true"></span>' +
400            ['All'].concat(categoryNames).map(function (name, i) {
401              return '<button class="segmented__btn' + (i === 0 ? ' is-active' : '') + '" type="button" aria-pressed="' + (i === 0) + '">' + esc(name) + '</button>';
402            }).join('') +
403          '</div>' +
404        '</div>';
405
406      seg = chipsWrap.querySelector('.segmented');
407      thumb = seg.querySelector('.segmented__thumb');
408      panel = chipsWrap.querySelector('.filter__panel');
409      var filterBtn = chipsWrap.querySelector('.filter__btn');
410
411      requestAnimationFrame(moveThumb);
412      window.addEventListener('resize', moveThumb);
413      if (document.fonts && document.fonts.ready) document.fonts.ready.then(moveThumb);
414
415      seg.addEventListener('click', function (e) {
416        var btn = e.target.closest('.segmented__btn');
417        if (btn) setCategory(btn.textContent);
418      });
419
420      filterBtn.addEventListener('click', function () {
421        var open = !panel.hidden;
422        panel.hidden = open;
423        filterBtn.setAttribute('aria-expanded', String(!open));
424        if (!open) syncPanel();
425      });
426      document.addEventListener('click', function (e) {
427        if (!panel.hidden && !e.target.closest('.filter')) {
428          panel.hidden = true;
429          filterBtn.setAttribute('aria-expanded', 'false');
430        }
431      });
432      document.addEventListener('keydown', function (e) {
433        if (e.key === 'Escape' && !panel.hidden) {
434          panel.hidden = true;
435          filterBtn.setAttribute('aria-expanded', 'false');
436          filterBtn.focus();
437        }
438      });
439
440      panel.addEventListener('click', function (e) {
441        var btn = e.target.closest('.filter__opt');
442        if (btn) {
443          var group = btn.getAttribute('data-group');
444          var value = btn.getAttribute('data-value');
445          if (group === 'sort') state.sort = value;
446          else if (group === 'category') { setCategory(value); return; }
447          else if (group === 'quarter') state.quarter = (value === 'All') ? null : value;
448          syncPanel();
449          resetPagingAndDraw();
450          return;
451        }
452        if (e.target.closest('.filter__clear')) {
453          state.sort = 'newest';
454          state.quarter = null;
455          state.query = '';
456          var searchInput = document.getElementById('episode-search');
457          if (searchInput) searchInput.value = '';
458          setCategory('All');
459        }
460      });
461      syncPanel();
462    }
463
464    // Search is handled globally by ui.js (Spotlight-style overlay with
465    // its own result cards), so the render layer doesn't wire it here.
466
467    if (grid) drawGrid();
468    // Home button is a link to /episodes.html; only the episodes page
469    // pages in more results (and hides the button once everything is shown)
470    if (moreBtn && isEpisodesPage) {
471      moreBtn.addEventListener('click', function () {
472        if (moreBtn.getAttribute('data-mode') === 'collapse') {
473          shownCount = EPISODES_PER_PAGE_FIRST;
474          drawGrid();
475          var top = document.getElementById('episodes');
476          if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });
477        } else {
478          shownCount += EPISODES_PER_PAGE_MORE;
479          drawGrid();
480        }
481      });
482    }
483  }
484
485  /* ---------- Single episode page ---------- */
486
487  function renderEpisode(data) {
488    var main = document.getElementById('episode-main');
489    if (!main) return;
490
491    var guid = TCC.currentEpisodeId();
492    var ep = data.episodes.find(function (e) { return e.guid === guid; });
493
494    if (!ep) {
495      main.innerHTML =
496        '<div class="episode-not-found">' +
497          '<h1>Episode not found</h1>' +
498          '<p>This episode may have been moved or is no longer in the feed.</p>' +
499          '<p><a class="btn btn--primary" href="index.html">← Back to all episodes</a></p>' +
500        '</div>';
501      document.title = 'Episode not found — The Connect Current';
502      return;
503    }
504
505    document.title = ep.title + ' — The Connect Current';
506    setMeta('description', previewText(ep.description));
507    setMeta('og:title', ep.title, true);
508    setMeta('og:description', previewText(ep.description), true);
509    setMeta('og:image', ep.imageUrl, true);
510
511    // JSON-LD PodcastEpisode: gives each episode page rich-result eligibility
512    // and clean sharing to Google, LinkedIn, and Discord.
513    var priorLd = document.querySelector('script[data-ld-episode]');
514    if (priorLd) priorLd.remove();
515    var ld = document.createElement('script');
516    ld.type = 'application/ld+json';
517    ld.setAttribute('data-ld-episode', '');
518    ld.textContent = JSON.stringify({
519      '@context': 'https://schema.org',
520      '@type': 'PodcastEpisode',
521      name: ep.title,
522      description: previewText(ep.description),
523      datePublished: ep.pubDate && !isNaN(ep.pubDate.getTime()) ? ep.pubDate.toISOString() : undefined,
524      url: window.location.href,
525      image: ep.imageUrl,
526      associatedMedia: {
527        '@type': 'MediaObject',
528        contentUrl: ep.audioUrl
529      },
530      partOfSeries: {
531        '@type': 'PodcastSeries',
532        name: 'The Connect Current',
533        url: 'https://theconnectcurrent.com/'
534      }
535    });
536    document.head.appendChild(ld);
537
538    main.innerHTML =
539      '<p class="episode-page__back"><a href="index.html">← Back to all episodes</a></p>' +
540      '<div class="episode-page__layout">' +
541        '<img class="episode-page__art" src="' + esc(ep.imageUrl) + '" alt="Artwork for ' + esc(ep.title) + '" width="360" height="360">' +
542        '<div class="episode-page__info">' +
543          '<p class="episode-page__meta mono">' +
544            'Episode ' + esc(ep.episodeNumber) + ' · ' + esc(formatDate(ep.pubDate)) +
545            (ep.duration ? ' · ' + esc(ep.duration) : '') +
546          '</p>' +
547          '<h1 class="episode-page__title">' + esc(ep.title) + '</h1>' +
548          '<div class="player-mount"></div>' +
549          '<ul class="platform-icons">' +
550            PLATFORMS.map(function (p) {
551              return '<li><a href="' + esc(p.url) + '" target="_blank" rel="noopener noreferrer" title="Listen on ' + esc(p.name) + '" aria-label="Listen on ' + esc(p.name) + '">' +
552                '<img src="assets/platforms/icons/' + esc(p.key) + '.png" alt="" width="26" height="26" loading="lazy"></a></li>';
553            }).join('') +
554          '</ul>' +
555          shareRow(ep) +
556        '</div>' +
557      '</div>' +
558      '<div class="episode-page__grid">' +
559        '<div>' +
560          '<div class="episode-page__notes">' + sanitizeHtml(ep.description) + '</div>' +
561          (ep.transcriptUrl ? '<div class="episode-page__transcript" id="transcript"><h2>Transcript</h2><div class="episode-page__transcript-body"><p class="loading-note">Loading transcript…</p></div></div>' : '') +
562        '</div>' +
563        // No "Listen On" card here: the platform icon row already sits
564        // directly under the player above, and repeating it reads as clutter.
565        '<aside class="sidebar">' + newsletterCard() + '</aside>' +
566      '</div>';
567
568    var epMount = main.querySelector('.player-mount');
569    if (epMount) {
570      TCC.createPlayer(epMount, ep.audioUrl, ep.title,
571        { image: ep.imageUrl, url: TCC.episodeUrl(ep.guid) });
572    }
573
574    // Share buttons
575    main.querySelectorAll('[data-share]').forEach(function (btn) {
576      btn.addEventListener('click', function () {
577        var kind = btn.getAttribute('data-share');
578        if (kind === 'native' && navigator.share) {
579          navigator.share({ title: ep.title, text: ep.title + ' — The Connect Current', url: window.location.href })
580            .catch(function () { /* visitor dismissed the sheet */ });
581          return;
582        }
583        var done = function () {
584          btn.classList.add('is-copied');
585          setTimeout(function () { btn.classList.remove('is-copied'); }, 1600);
586        };
587        if (navigator.clipboard && navigator.clipboard.writeText) {
588          navigator.clipboard.writeText(window.location.href).then(done, function () {});
589        } else {
590          var tmp = document.createElement('input');
591          tmp.value = window.location.href;
592          document.body.appendChild(tmp);
593          tmp.select();
594          try { document.execCommand('copy'); done(); } catch (e) { /* ignore */ }
595          document.body.removeChild(tmp);
596        }
597      });
598    });
599
600    // Transcript is fetched through our proxy (same-origin), which only
601    // relays URLs from the podcast host — appears automatically once
602    // Resonate adds <podcast:transcript> to the feed
603    if (ep.transcriptUrl) {
604      var tBody = main.querySelector('.episode-page__transcript-body');
605      fetch('./feed-proxy.php?transcript=' + encodeURIComponent(ep.transcriptUrl))
606        .then(function (res) {
607          if (!res.ok) throw new Error('HTTP ' + res.status);
608          return res.text();
609        })
610        .then(function (text) {
611          if (ep.transcriptType.indexOf('html') !== -1) {
612            tBody.innerHTML = sanitizeHtml(text);
613          } else {
614            // Plain text / VTT / SRT → readable paragraphs
615            var clean = text.replace(/^WEBVTT.*$/m, '')
616              .replace(/^\d+\s*$/gm, '')
617              .replace(/^[\d:.,>\- ]+-->.*$/gm, '')
618              .replace(/\n{3,}/g, '\n\n').trim();
619            tBody.innerHTML = clean.split(/\n\n+/).map(function (p) {
620              return '<p>' + esc(p.replace(/\n/g, ' ')) + '</p>';
621            }).join('');
622          }
623        })
624        .catch(function () {
625          tBody.innerHTML = '<p>Transcript is temporarily unavailable.' +
626            (ep.link ? ' You can also read it on the <a href="' + esc(ep.link) + '" target="_blank" rel="noopener noreferrer">episode page</a>.' : '') + '</p>';
627        });
628    }
629  }
630
631  function setMeta(name, content, isProperty) {
632    var selector = isProperty ? 'meta[property="' + name + '"]' : 'meta[name="' + name + '"]';
633    var el = document.querySelector(selector);
634    if (el) el.setAttribute('content', content);
635  }
636
637  /* ---------- Boot ---------- */
638
639  function onReady(fn) {
640    if (document.readyState !== 'loading') fn();
641    else document.addEventListener('DOMContentLoaded', fn);
642  }
643
644  onReady(function () {
645    var page = document.body.getAttribute('data-page');
646    // 'home' and 'episodes' share the grid renderer; about.html is static
647    var isList = (page === 'home' || page === 'episodes');
648    if (!isList && page !== 'episode') return;
649
650    // The dedicated episodes page shows a deeper first page
651    if (page === 'episodes') EPISODES_PER_PAGE_FIRST = 12;
652
653    TCC.loadFeed()
654      .then(function (data) {
655        if (isList) renderHome(data);
656        else renderEpisode(data);
657      })
658      .catch(function (err) {
659        console.warn('[TCC] Could not load episodes:', err.message);
660        if (isList) {
661          showUnavailable(document.getElementById('episode-grid'));
662          showUnavailable(document.getElementById('featured-episodes'), 'Featured episodes will appear here shortly.');
663          var heroPlayer = document.getElementById('hero-player');
664          if (heroPlayer) heroPlayer.innerHTML = '';
665        } else {
666          showUnavailable(document.getElementById('episode-main'));
667        }
668      });
669  });
670})();

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.