Replace Spotify embeds with branded custom audio player backed by RSS
- 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 <noreply@anthropic.com>
This commit is contained in:
+34
-5
@@ -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
|
||||
<input id={`study-section-title-${study.id}-${section.id}`} type="text" value={section.title} placeholder="Paul's Greeting" onChange={e => updateStudySection(study.id, section.id, 'title', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-audio-${study.id}-${section.id}`}>Spotify Embed URL (optional)</label>
|
||||
<input id={`study-section-audio-${study.id}-${section.id}`} type="url" value={section.audioEmbedUrl ?? ''} placeholder="https://open.spotify.com/embed/episode/..." onChange={e => updateStudySection(study.id, section.id, 'audioEmbedUrl', e.target.value)} />
|
||||
<label htmlFor={`study-section-audio-${study.id}-${section.id}`}>Episode Audio (optional)</label>
|
||||
<select id={`study-section-audio-${study.id}-${section.id}`} value={section.audioEmbedUrl ?? ''} onChange={e => updateStudySection(study.id, section.id, 'audioEmbedUrl', e.target.value)}>
|
||||
<option value="">— No audio —</option>
|
||||
{rssEpisodes.map(ep => (
|
||||
<option key={ep.audioUrl} value={ep.audioUrl}>{ep.title}</option>
|
||||
))}
|
||||
{section.audioEmbedUrl && !rssEpisodes.some(ep => ep.audioUrl === section.audioEmbedUrl) && (
|
||||
<option value={section.audioEmbedUrl}>{section.audioEmbedUrl}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`study-section-released-${study.id}-${section.id}`}>Release Date (optional)</label>
|
||||
@@ -1102,6 +1111,8 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [qrEditLabel, setQrEditLabel] = useState('')
|
||||
const [qrEditDest, setQrEditDest] = useState('')
|
||||
|
||||
const [rssEpisodes, setRssEpisodes] = useState<Array<{ title: string; audioUrl: string; duration: string; episode: string }>>([])
|
||||
|
||||
const [podcastChecklist, setPodcastChecklist] = useState<PodcastChecklistData>({ 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) {
|
||||
<input id={`podcast-url-${item.id}`} type="text" placeholder="https://open.spotify.com/..." value={item.url} onChange={e => updatePodcastLink(item.id, 'url', e.target.value)} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`podcast-embed-${item.id}`}>Embed URL (optional — enables in-page player)</label>
|
||||
<input id={`podcast-embed-${item.id}`} type="text" placeholder="https://open.spotify.com/embed/episode/..." value={item.embedUrl ?? ''} onChange={e => updatePodcastLink(item.id, 'embedUrl', e.target.value)} />
|
||||
<label htmlFor={`podcast-embed-${item.id}`}>Episode Audio (optional — enables in-page player)</label>
|
||||
<select id={`podcast-embed-${item.id}`} value={item.embedUrl ?? ''} onChange={e => updatePodcastLink(item.id, 'embedUrl', e.target.value)}>
|
||||
<option value="">— No audio —</option>
|
||||
{rssEpisodes.map(ep => (
|
||||
<option key={ep.audioUrl} value={ep.audioUrl}>{ep.title}</option>
|
||||
))}
|
||||
{item.embedUrl && !rssEpisodes.some(ep => ep.audioUrl === item.embedUrl) && (
|
||||
<option value={item.embedUrl}>{item.embedUrl}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor={`podcast-notes-${item.id}`}>Show Notes</label>
|
||||
@@ -4775,6 +4803,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
study={study}
|
||||
updateStudySection={updateStudySection}
|
||||
removeStudySection={removeStudySection}
|
||||
rssEpisodes={rssEpisodes}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
Reference in New Issue
Block a user