Website overhaul: nav, accessibility, shared components, SEO, Reaper silence detection

Website:
- Add persistent top nav across all pages
- Add skip-to-content links, focus-visible styles, ARIA on audio player
- Fix text contrast for WCAG AA compliance
- Add 600px breakpoint, mobile typography scaling
- Extract shared footer.js, player.js, episode.js components
- Episode pagination (10 + Load More), featured clip dedup
- Worker meta injection for social crawler OG tags
- Unify Plausible analytics proxy across all pages
- Sanitize innerHTML for XSS safety
- Custom 404 page, enhanced llms.txt, fix sitemap
- Bump cache versions to v=4

Reaper:
- Add dual silence threshold: 2.5s for speaker transitions, 6s for same-speaker gaps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 00:56:29 -06:00
parent c70f83d04a
commit d39cb3f3d4
20 changed files with 870 additions and 678 deletions
+66 -89
View File
@@ -1,25 +1,14 @@
const FEED_URL = '/feed';
const EPISODES_PER_PAGE = 10;
const audio = document.getElementById('audio-element');
const stickyPlayer = document.getElementById('sticky-player');
const playerPlayBtn = document.getElementById('player-play-btn');
const playerTitle = document.getElementById('player-title');
const playerProgress = document.getElementById('player-progress');
const playerProgressFill = document.getElementById('player-progress-fill');
const playerTime = document.getElementById('player-time');
const episodesList = document.getElementById('episodes-list');
let currentEpisodeCard = null;
let allEpisodes = [];
let displayedCount = 0;
// Format seconds to M:SS or H:MM:SS
function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
const s = Math.floor(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
return `${m}:${String(sec).padStart(2, '0')}`;
function escapeAttr(str) {
return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Format duration from itunes:duration (could be seconds or HH:MM:SS)
@@ -43,13 +32,20 @@ function formatDate(dateStr) {
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
// Strip HTML tags and truncate
// Strip HTML tags and truncate at word boundary (returns escaped text safe for innerHTML)
function truncate(html, maxLen) {
const div = document.createElement('div');
div.innerHTML = html || '';
const text = div.textContent || '';
if (text.length <= maxLen) return text;
return text.slice(0, maxLen).trimEnd() + '...';
let result;
if (text.length <= maxLen) {
result = text;
} else {
const truncated = text.slice(0, maxLen);
const lastSpace = truncated.lastIndexOf(' ');
result = (lastSpace > maxLen * 0.5 ? truncated.slice(0, lastSpace) : truncated).trimEnd() + '...';
}
return escapeAttr(result);
}
// SVG icons
@@ -91,7 +87,7 @@ async function fetchEpisodes() {
return;
}
const episodes = Array.from(items).map((item, i) => {
const episodes = Array.from(items).map((item) => {
const title = item.querySelector('title')?.textContent || 'Untitled';
const description = item.querySelector('description')?.textContent || '';
const enclosure = item.querySelector('enclosure');
@@ -108,42 +104,64 @@ async function fetchEpisodes() {
}
function renderEpisodes(episodes) {
allEpisodes = episodes;
displayedCount = 0;
episodesList.innerHTML = '';
showMoreEpisodes();
}
episodes.forEach((ep) => {
const card = document.createElement('div');
card.className = 'episode-card';
function createEpisodeCard(ep) {
const card = document.createElement('div');
card.className = 'episode-card';
const epLabel = ep.episodeNum ? `Ep ${ep.episodeNum}` : '';
const dateStr = ep.pubDate ? formatDate(ep.pubDate) : '';
const durStr = parseDuration(ep.duration);
const epLabel = ep.episodeNum ? `Ep ${ep.episodeNum}` : '';
const dateStr = ep.pubDate ? formatDate(ep.pubDate) : '';
const durStr = parseDuration(ep.duration);
const metaParts = [epLabel, dateStr, durStr].filter(Boolean).join(' &middot; ');
const epSlug = ep.link ? ep.link.split('/episodes/').pop()?.replace(/\/$/, '') : '';
const metaParts = [epLabel, dateStr, durStr].filter(Boolean).join(' &middot; ');
const epSlug = ep.link ? ep.link.split('/episodes/').pop()?.replace(/\/$/, '') : '';
card.innerHTML = `
<button class="episode-play-btn" aria-label="Play ${ep.title}" data-url="${ep.audioUrl}" data-title="${ep.title.replace(/"/g, '&quot;')}">
${playSVG}
</button>
<div class="episode-info">
<div class="episode-meta">${metaParts}</div>
<div class="episode-title">${ep.title}</div>
<div class="episode-desc">${truncate(ep.description, 150)}</div>
${epSlug ? `<a href="/episode.html?slug=${epSlug}" class="episode-transcript-link">Read Transcript</a>` : ''}
</div>
`;
card.innerHTML = `
<button class="episode-play-btn" aria-label="Play ${escapeAttr(ep.title)}" data-url="${escapeAttr(ep.audioUrl)}" data-title="${escapeAttr(ep.title)}">
${playSVG}
</button>
<div class="episode-info">
<div class="episode-meta">${metaParts}</div>
<div class="episode-title">${escapeAttr(ep.title)}</div>
<div class="episode-desc">${truncate(ep.description, 150)}</div>
${epSlug ? `<a href="/episode.html?slug=${encodeURIComponent(epSlug)}" class="episode-transcript-link">Read Transcript</a>` : ''}
</div>
`;
const btn = card.querySelector('.episode-play-btn');
btn.addEventListener('click', () => playEpisode(ep.audioUrl, ep.title, card, btn));
const btn = card.querySelector('.episode-play-btn');
btn.addEventListener('click', () => playEpisode(ep.audioUrl, ep.title, card, btn));
episodesList.appendChild(card);
return card;
}
function showMoreEpisodes() {
const batch = allEpisodes.slice(displayedCount, displayedCount + EPISODES_PER_PAGE);
batch.forEach((ep) => {
episodesList.appendChild(createEpisodeCard(ep));
});
displayedCount += batch.length;
const existing = document.getElementById('load-more-btn');
if (existing) existing.remove();
if (displayedCount < allEpisodes.length) {
const btn = document.createElement('button');
btn.id = 'load-more-btn';
btn.className = 'load-more-btn';
btn.textContent = `Load More (${allEpisodes.length - displayedCount} remaining)`;
btn.addEventListener('click', showMoreEpisodes);
episodesList.after(btn);
}
}
function playEpisode(url, title, card, btn) {
if (!url) return;
// If clicking the same episode that's playing, toggle play/pause
if (audio.src === url || audio.src === encodeURI(url)) {
if (audio.paused) {
audio.play();
@@ -153,7 +171,6 @@ function playEpisode(url, title, card, btn) {
return;
}
// Reset previous card button icon
if (currentEpisodeCard) {
const prevBtn = currentEpisodeCard.querySelector('.episode-play-btn');
if (prevBtn) {
@@ -170,35 +187,8 @@ function playEpisode(url, title, card, btn) {
stickyPlayer.classList.add('active');
}
// Sync UI with audio state
audio.addEventListener('play', () => {
updatePlayIcons(true);
});
audio.addEventListener('pause', () => {
updatePlayIcons(false);
});
audio.addEventListener('timeupdate', () => {
if (audio.duration) {
const pct = (audio.currentTime / audio.duration) * 100;
playerProgressFill.style.width = pct + '%';
playerTime.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
}
});
audio.addEventListener('ended', () => {
updatePlayIcons(false);
});
function updatePlayIcons(playing) {
// Sticky player icons
const iconPlay = playerPlayBtn.querySelector('.icon-play');
const iconPause = playerPlayBtn.querySelector('.icon-pause');
if (iconPlay) iconPlay.style.display = playing ? 'none' : 'block';
if (iconPause) iconPause.style.display = playing ? 'block' : 'none';
// Episode card icon
// Episode card icon sync (sticky player icons handled by player.js)
function updateCardIcon(playing) {
if (currentEpisodeCard) {
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
if (btn) {
@@ -208,22 +198,9 @@ function updatePlayIcons(playing) {
}
}
// Sticky player play/pause button
playerPlayBtn.addEventListener('click', () => {
if (audio.src) {
if (audio.paused) audio.play();
else audio.pause();
}
});
// Progress bar seeking
playerProgress.addEventListener('click', (e) => {
if (audio.duration) {
const rect = playerProgress.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audio.currentTime = pct * audio.duration;
}
});
audio.addEventListener('play', () => updateCardIcon(true));
audio.addEventListener('pause', () => updateCardIcon(false));
audio.addEventListener('ended', () => updateCardIcon(false));
// Testimonials Slider
function initTestimonials() {