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&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.