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:
+66
-89
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// 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(' · ');
|
||||
const epSlug = ep.link ? ep.link.split('/episodes/').pop()?.replace(/\/$/, '') : '';
|
||||
const metaParts = [epLabel, dateStr, durStr].filter(Boolean).join(' · ');
|
||||
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, '"')}">
|
||||
${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() {
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ async function initClipsPage() {
|
||||
}
|
||||
|
||||
if (gridContainer) {
|
||||
clips.forEach(clip => {
|
||||
clips.filter(c => !c.featured).forEach(clip => {
|
||||
gridContainer.appendChild(renderClipCard(clip, false));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
const FEED_URL = '/feed';
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function parseDuration(raw) {
|
||||
if (!raw) return '';
|
||||
if (raw.includes(':')) {
|
||||
const parts = raw.split(':').map(Number);
|
||||
let t = 0;
|
||||
if (parts.length === 3) t = parts[0]*3600 + parts[1]*60 + parts[2];
|
||||
else if (parts.length === 2) t = parts[0]*60 + parts[1];
|
||||
return `${Math.round(t/60)} min`;
|
||||
}
|
||||
const sec = parseInt(raw, 10);
|
||||
return isNaN(sec) ? '' : `${Math.round(sec/60)} min`;
|
||||
}
|
||||
|
||||
function stripHtml(html) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html || '';
|
||||
return div.textContent || '';
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Get slug from URL
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const slug = params.get('slug');
|
||||
|
||||
if (!slug) {
|
||||
document.getElementById('ep-title').textContent = 'Episode not found';
|
||||
document.getElementById('transcript-body').innerHTML = '<p>No episode specified. <a href="/">Go back to episodes.</a></p>';
|
||||
} else {
|
||||
loadEpisode(slug);
|
||||
}
|
||||
|
||||
async function loadEpisode(slug) {
|
||||
try {
|
||||
const res = await fetch(FEED_URL);
|
||||
const xml = await res.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, 'text/xml');
|
||||
const items = doc.querySelectorAll('item');
|
||||
|
||||
let episode = null;
|
||||
for (const item of items) {
|
||||
const link = item.querySelector('link')?.textContent || '';
|
||||
const itemSlug = link.split('/episodes/').pop()?.replace(/\/$/, '');
|
||||
if (itemSlug === slug) {
|
||||
episode = {
|
||||
title: item.querySelector('title')?.textContent || 'Untitled',
|
||||
description: item.querySelector('description')?.textContent || '',
|
||||
audioUrl: item.querySelector('enclosure')?.getAttribute('url') || '',
|
||||
pubDate: item.querySelector('pubDate')?.textContent || '',
|
||||
duration: item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'duration')[0]?.textContent || '',
|
||||
episodeNum: item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'episode')[0]?.textContent || '',
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!episode) {
|
||||
document.getElementById('ep-title').textContent = 'Episode not found';
|
||||
document.getElementById('transcript-body').innerHTML = '<p>Could not find this episode. <a href="/">Go back to episodes.</a></p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate header
|
||||
const metaParts = [
|
||||
episode.episodeNum ? `Episode ${episode.episodeNum}` : '',
|
||||
episode.pubDate ? formatDate(episode.pubDate) : '',
|
||||
parseDuration(episode.duration),
|
||||
].filter(Boolean).join(' \u00b7 ');
|
||||
|
||||
document.getElementById('ep-meta').textContent = metaParts;
|
||||
document.getElementById('ep-title').textContent = episode.title;
|
||||
document.getElementById('ep-desc').textContent = stripHtml(episode.description || '');
|
||||
|
||||
// Update page meta
|
||||
document.title = `${episode.title} — Luke at the Roost`;
|
||||
document.getElementById('page-description')?.setAttribute('content', `Full transcript of ${episode.title} from Luke at the Roost.`);
|
||||
document.getElementById('og-title')?.setAttribute('content', episode.title);
|
||||
document.getElementById('og-description')?.setAttribute('content', stripHtml(episode.description).slice(0, 200));
|
||||
const canonicalUrl = `https://lukeattheroost.com/episode.html?slug=${slug}`;
|
||||
document.getElementById('page-canonical')?.setAttribute('href', canonicalUrl);
|
||||
document.getElementById('og-url')?.setAttribute('content', canonicalUrl);
|
||||
document.getElementById('tw-title')?.setAttribute('content', episode.title);
|
||||
document.getElementById('tw-description')?.setAttribute('content', stripHtml(episode.description).slice(0, 200));
|
||||
|
||||
// Update JSON-LD structured data
|
||||
const jsonLd = document.getElementById('episode-jsonld');
|
||||
if (jsonLd) {
|
||||
const ld = JSON.parse(jsonLd.textContent);
|
||||
ld.name = episode.title;
|
||||
ld.url = canonicalUrl;
|
||||
ld.description = stripHtml(episode.description).slice(0, 300);
|
||||
if (episode.pubDate) ld.datePublished = new Date(episode.pubDate).toISOString().split('T')[0];
|
||||
if (episode.episodeNum) ld.episodeNumber = parseInt(episode.episodeNum, 10);
|
||||
if (episode.audioUrl) {
|
||||
ld.associatedMedia = {
|
||||
"@type": "MediaObject",
|
||||
"contentUrl": episode.audioUrl
|
||||
};
|
||||
}
|
||||
jsonLd.textContent = JSON.stringify(ld);
|
||||
}
|
||||
|
||||
// Play button
|
||||
if (episode.audioUrl) {
|
||||
const playBtn = document.getElementById('ep-play-btn');
|
||||
playBtn.style.display = 'inline-flex';
|
||||
playBtn.addEventListener('click', () => {
|
||||
audio.src = episode.audioUrl;
|
||||
audio.play();
|
||||
playerTitle.textContent = episode.title;
|
||||
stickyPlayer.classList.add('active');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('ep-title').textContent = 'Error loading episode';
|
||||
}
|
||||
|
||||
// Fetch transcript
|
||||
try {
|
||||
const txRes = await fetch(`/transcripts/${slug}.txt`);
|
||||
if (!txRes.ok) throw new Error('Not found');
|
||||
const text = await txRes.text();
|
||||
const paragraphs = text.split(/\n\n+/).filter(Boolean);
|
||||
const html = paragraphs.map(p => {
|
||||
const escaped = escapeHtml(p);
|
||||
const labeled = escaped.replace(/^([A-Z][A-Z\s'\-]+?):\s*/, '<span class="speaker-label">$1:</span> ');
|
||||
return `<p>${labeled.replace(/\n/g, '<br>')}</p>`;
|
||||
}).join('');
|
||||
document.getElementById('transcript-body').innerHTML = html;
|
||||
} catch (e) {
|
||||
document.getElementById('transcript-body').innerHTML = '<p class="transcript-unavailable">Transcript not yet available for this episode.</p>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
function initFooter() {
|
||||
const footer = document.querySelector('.footer');
|
||||
if (!footer) return;
|
||||
|
||||
footer.innerHTML = `
|
||||
<div class="footer-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/how-it-works">How It Works</a>
|
||||
<a href="/clips">Clips</a>
|
||||
<a href="/stats">Stats</a>
|
||||
</div>
|
||||
<div class="footer-icons">
|
||||
<span class="footer-icons-label">Listen On</span>
|
||||
<div class="footer-icons-row">
|
||||
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Spotify"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg></a>
|
||||
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener" class="footer-icon-link" aria-label="YouTube"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z"/><path d="M9.545 15.568V8.432L15.818 12z" fill="#1a1209"/></svg></a>
|
||||
<a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Apple Podcasts"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12c0 3.293 1.592 6.214 4.05 8.04.13-.455.283-.942.457-1.393A9 9 0 0 1 3 12a9 9 0 0 1 18 0 9 9 0 0 1-3.507 7.127c.174.42.327.893.456 1.333A10 10 0 0 0 22 12c0-5.523-4.477-10-10-10zm0 4a6 6 0 0 0-6 6c0 1.87.856 3.54 2.2 4.64.196-.46.43-.91.692-1.31A4.5 4.5 0 0 1 7.5 12a4.5 4.5 0 0 1 9 0c0 1.21-.478 2.31-1.256 3.12.24.37.462.8.655 1.24A6 6 0 0 0 18 12a6 6 0 0 0-6-6zm0 4.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM12 15c-.75 0-1.158.54-1.28 1.2-.17.94-.28 1.91-.33 2.88-.03.48.34.82.73.82h1.76c.39 0 .76-.34.73-.82-.05-.97-.16-1.94-.33-2.88-.122-.66-.53-1.2-1.28-1.2z"/></svg></a>
|
||||
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener" class="footer-icon-link" aria-label="RSS"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248S0 22.546 0 20.752s1.456-3.248 3.252-3.248 3.251 1.454 3.251 3.248zM.002 9.473v4.594c5.508.163 9.929 4.584 10.092 10.091h4.594C14.524 16.21 7.849 9.636.002 9.473zM.006 0v4.604C10.81 4.77 19.23 13.19 19.396 24h4.604C23.834 10.952 13.054.166.006 0z"/></svg></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-icons">
|
||||
<span class="footer-icons-label">Follow</span>
|
||||
<div class="footer-icons-row">
|
||||
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Discord"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg></a>
|
||||
<a href="https://www.facebook.com/profile.php?id=61588191627949" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Facebook"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg></a>
|
||||
<a href="https://www.instagram.com/lukeattheroost/" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Instagram"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg></a>
|
||||
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="X"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg></a>
|
||||
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Bluesky"><svg viewBox="0 0 568 501" fill="currentColor"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C10.945 203.659 1 75.291 1 57.946 1-28.906 76.134-1.612 123.121 33.664z"/></svg></a>
|
||||
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener" class="footer-icon-link" aria-label="Mastodon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054 19.648 19.648 0 0 0 4.636.528c.164 0 .329 0 .494-.002 1.694-.042 3.48-.152 5.12-.554 2.21-.543 4.137-2.186 4.348-4.55.162-1.808.21-3.627.142-5.43-.02-.6-.168-1.874-.168-1.874z"/><path d="M19.903 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192zM9.337 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192z" fill="#1a1209"/></svg></a>
|
||||
<a href="https://primal.net/p/nprofile1qqswsam9cx06j7sxzpl498uquk3kgrwedxtq48j57zxkuj8fs82xtugge0wtg" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Nostr"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186.31a.27.27 0 0 0-.372 0C8.46 3.487 2.666 9.93 2.666 15.042c0 5.176 4.183 8.958 9.334 8.958s9.334-3.782 9.334-8.958c0-5.112-5.794-11.555-9.148-14.732z"/></svg></a>
|
||||
<a href="https://www.threads.com/@lukeattheroost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Threads"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.59 12c.025 3.086.718 5.496 2.057 7.164 1.432 1.781 3.632 2.695 6.54 2.717 2.227-.017 4.048-.59 5.413-1.703 1.428-1.163 2.076-2.645 1.925-4.403-.098-1.13-.578-2.065-1.39-2.7-.811-.636-1.905-.993-3.164-1.033a11.253 11.253 0 0 0-.04 0c-1.078.007-2.044.289-2.79.816-.68.481-1.069 1.108-1.125 1.813-.057.72.264 1.32.877 1.64.554.29 1.317.437 2.271.437l.013-.001c.652-.004 1.383-.078 2.172-.218l.386 2.022c-.947.18-1.837.273-2.643.278a10.35 10.35 0 0 1-.143 0c-1.425-.013-2.657-.284-3.66-.804-1.237-.643-1.928-1.745-1.836-2.93.099-1.258.738-2.316 1.849-3.064 1.088-.732 2.466-1.12 3.988-1.124h.05c1.644.044 3.088.528 4.178 1.398 1.133.905 1.8 2.185 1.935 3.703.2 2.258-.697 4.2-2.598 5.75-1.668 1.36-3.863 2.087-6.348 2.105z"/></svg></a>
|
||||
<a href="https://www.linkedin.com/company/luke-at-the-roost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="LinkedIn"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg></a>
|
||||
<a href="https://www.tiktok.com/@luke.at.the.roost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="TikTok"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-projects">
|
||||
<span class="footer-projects-label">More from Luke</span>
|
||||
<div class="footer-projects-links">
|
||||
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
|
||||
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
|
||||
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
||||
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
|
||||
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="/terms">Terms of Service</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
||||
`;
|
||||
}
|
||||
|
||||
initFooter();
|
||||
@@ -0,0 +1,45 @@
|
||||
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');
|
||||
|
||||
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 updatePlayIcons(playing) {
|
||||
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';
|
||||
}
|
||||
|
||||
audio.addEventListener('play', () => updatePlayIcons(true));
|
||||
audio.addEventListener('pause', () => updatePlayIcons(false));
|
||||
audio.addEventListener('ended', () => updatePlayIcons(false));
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
if (audio.duration) {
|
||||
playerProgressFill.style.width = (audio.currentTime / audio.duration * 100) + '%';
|
||||
playerTime.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
|
||||
}
|
||||
});
|
||||
|
||||
playerPlayBtn.addEventListener('click', () => {
|
||||
if (audio.src) { audio.paused ? audio.play() : audio.pause(); }
|
||||
});
|
||||
|
||||
playerProgress.addEventListener('click', (e) => {
|
||||
if (audio.duration) {
|
||||
const rect = playerProgress.getBoundingClientRect();
|
||||
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user