From a1d588971192b65466984af2f833e01eb06a2d1a Mon Sep 17 00:00:00 2001 From: nmemmert Date: Wed, 17 Jun 2026 13:23:19 -0400 Subject: [PATCH] Replace Spotify embeds with branded custom audio player backed by RSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /api/episode-audio route returning MP3 URLs from Anchor RSS feed - Replace Spotify URL text inputs in admin with RSS episode dropdowns - New EpisodeAudioPlayer component with podcast art, show name, progress bar, and Spotify icon link — full and compact sizes - Backward-compatible: legacy Spotify embed URLs still render as iframes Co-Authored-By: Claude Sonnet 4.6 --- server/routes/episodes.js | 11 ++ src/AdminPage.tsx | 39 +++++- src/App.css | 172 ++++++++++++++++++++++++++ src/App.tsx | 23 ++-- src/colossiansStudy.tsx | 27 ++-- src/components/EpisodeAudioPlayer.tsx | 146 ++++++++++++++++++++++ 6 files changed, 393 insertions(+), 25 deletions(-) create mode 100644 src/components/EpisodeAudioPlayer.tsx diff --git a/server/routes/episodes.js b/server/routes/episodes.js index 5d7b901..38914de 100644 --- a/server/routes/episodes.js +++ b/server/routes/episodes.js @@ -31,6 +31,7 @@ function parseRssItems(xml, limit = Infinity) { const episode = (/([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim() items.push({ title, pubDate, link, + audioUrl: enclosureUrl, description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''), duration, episode, }) @@ -174,4 +175,14 @@ export function register(app) { res.json({ episodes: episodesCache ?? [] }) } }) + + app.get('/api/episode-audio', async (_req, res) => { + try { + const episodes = await fetchAllEpisodes() + res.json({ episodes: episodes.map(e => ({ title: e.title, audioUrl: e.audioUrl, duration: e.duration, episode: e.episode })) }) + } catch (err) { + console.error('[episode-audio] RSS fetch error:', err.message) + res.json({ episodes: (episodesCache ?? []).map(e => ({ title: e.title, audioUrl: e.audioUrl, duration: e.duration, episode: e.episode })) }) + } + }) } diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 099165d..fc86afc 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -15,9 +15,10 @@ interface SortableLessonSectionProps { study: StudyProgram updateStudySection: (studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | string[] | number) => void removeStudySection: (studyId: string, sectionId: string) => void + rssEpisodes: Array<{ title: string; audioUrl: string; duration: string; episode: string }> } -function SortableLessonSection({ section, study, updateStudySection, removeStudySection }: SortableLessonSectionProps) { +function SortableLessonSection({ section, study, updateStudySection, removeStudySection, rssEpisodes }: SortableLessonSectionProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id }) const style = { transform: CSS.Transform.toString(transform), @@ -60,8 +61,16 @@ function SortableLessonSection({ section, study, updateStudySection, removeStudy updateStudySection(study.id, section.id, 'title', e.target.value)} />
- - updateStudySection(study.id, section.id, 'audioEmbedUrl', e.target.value)} /> + +
@@ -1102,6 +1111,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [qrEditLabel, setQrEditLabel] = useState('') const [qrEditDest, setQrEditDest] = useState('') + const [rssEpisodes, setRssEpisodes] = useState>([]) + const [podcastChecklist, setPodcastChecklist] = useState({ tasks: [], episodes: [] }) const [podcastChecklistStatus, setPodcastChecklistStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [podcastChecklistMsg, setPodcastChecklistMsg] = useState('') @@ -1237,6 +1248,15 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { return () => window.removeEventListener('beforeunload', handleBeforeUnload) }, [isDirty]) + useEffect(() => { + fetch('/api/episode-audio') + .then(r => r.ok ? r.json() : Promise.reject()) + .then((data: { episodes: Array<{ title: string; audioUrl: string; duration: string; episode: string }> }) => { + setRssEpisodes(data.episodes ?? []) + }) + .catch(() => {}) + }, []) + useEffect(() => { fetch('/api/admin-auth/status') .then(r => r.ok ? r.json() : Promise.reject()) @@ -3913,8 +3933,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { updatePodcastLink(item.id, 'url', e.target.value)} />
- - updatePodcastLink(item.id, 'embedUrl', e.target.value)} /> + +
@@ -4775,6 +4803,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { study={study} updateStudySection={updateStudySection} removeStudySection={removeStudySection} + rssEpisodes={rssEpisodes} /> ))} diff --git a/src/App.css b/src/App.css index 8bdda19..a84406a 100644 --- a/src/App.css +++ b/src/App.css @@ -2702,6 +2702,178 @@ margin: 0 0 2.5rem; } +/* Custom audio player */ +.episode-audio-player { + background: var(--brand-black-2, #0f0f0c); + border: 1px solid rgba(201, 168, 76, 0.2); + border-radius: 10px; + padding: 1rem 1.1rem; +} + +.episode-audio-player__inner { + display: flex; + align-items: center; + gap: 14px; +} + +.episode-audio-player__art { + width: 56px; + height: 56px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; +} + +.episode-audio-player__body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.episode-audio-player__meta { + display: flex; + align-items: center; + justify-content: space-between; +} + +.episode-audio-player__show-name { + font-size: 0.7rem; + color: var(--brand-gold, #c9a84c); + font-family: var(--brand-font-body, Georgia, serif); + letter-spacing: 0.02em; +} + +.episode-audio-player__spotify-link { + color: rgba(176, 164, 140, 0.5); + display: flex; + align-items: center; + transition: color 0.15s; + flex-shrink: 0; +} + +.episode-audio-player__spotify-link:hover { + color: #1DB954; +} + +.episode-audio-player__spotify-link svg { + width: 16px; + height: 16px; +} + +.episode-audio-player__title { + font-size: 0.88rem; + color: var(--brand-warm-white, #f0ead8); + margin: 0; + font-family: var(--brand-font-heading, Georgia, serif); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.episode-audio-player__controls { + display: flex; + align-items: center; + gap: 10px; + margin-top: 2px; +} + +.episode-audio-player__play { + width: 34px; + height: 34px; + border-radius: 50%; + background: var(--brand-gold, #c9a84c); + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--brand-black, #0a0a08); + transition: background 0.15s; +} + +.episode-audio-player__play:hover { + background: var(--brand-gold-light, #e0c070); +} + +.episode-audio-player__play svg { + width: 14px; + height: 14px; +} + +.episode-audio-player__track { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.episode-audio-player__bar { + position: relative; + height: 3px; + background: rgba(42, 37, 24, 0.9); + border-radius: 2px; + cursor: pointer; + outline: none; +} + +.episode-audio-player__bar:focus-visible { + box-shadow: 0 0 0 2px var(--brand-gold, #c9a84c); +} + +.episode-audio-player__fill { + height: 100%; + background: var(--brand-gold, #c9a84c); + border-radius: 2px; + pointer-events: none; +} + +.episode-audio-player__thumb { + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--brand-gold-light, #e0c070); + pointer-events: none; +} + +.episode-audio-player__times { + display: flex; + justify-content: space-between; + font-size: 0.68rem; + color: var(--brand-muted, #b0a48c); + font-family: var(--brand-font-body, Georgia, serif); +} + +/* Compact variant (study sections) */ +.episode-audio-player--compact .episode-audio-player__art { + width: 42px; + height: 42px; +} + +.episode-audio-player--compact .episode-audio-player__play { + width: 28px; + height: 28px; +} + +.episode-audio-player--compact .episode-audio-player__play svg { + width: 11px; + height: 11px; +} + +.episode-audio-player--compact .episode-audio-player__bar { + height: 2px; +} + +.episode-audio-player--compact .episode-audio-player__title { + font-size: 0.8rem; +} + .episode-detail-show-notes, .episode-detail-questions { margin-top: 2.5rem; diff --git a/src/App.tsx b/src/App.tsx index 6596f98..e43ff30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { DEFAULTS } from './content' import { usePageMeta } from './hooks/usePageMeta' import { useGlobalSearch } from './hooks/useGlobalSearch' import { GlobalSearch } from './components/GlobalSearch' +import { EpisodeAudioPlayer } from './components/EpisodeAudioPlayer' import './App.css' const SPOTIFY_EMBED_URL = @@ -1564,15 +1565,19 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) { {resolvedEmbedUrl && (
-