Add Verse By Verse: Nuggets sub-show with editable hosts and RSS feed
Publish Container / docker (push) Successful in 14m16s

New /nuggets page for the companion show, linked from the Episodes nav
dropdown. Hosts (name, role, bio, photo) show in a left column with the
episode list on the right — latest episode featured, rest below.
Episodes are pulled from an admin-configurable RSS feed URL (empty by
default, shows "coming soon" until set) rather than hardcoded like the
main show's feed, since the show doesn't have one yet.

Everything is editable in /admin under a new Nuggets section: Show
Settings (title, tagline, description, podcast art, RSS/platform URLs)
and Hosts (add freely; removing a host archives them instead of
deleting — kept on record and restorable, never lost).

Extracted the RSS item-parsing logic shared with the main show's feed
(server/routes/episodes.js) into server/rss-helpers.js so both feeds
use the same parser instead of duplicating it.

Also fixes the local Vite dev server not proxying /uploads/* to the
backend — it was silently serving the SPA shell (200, text/html)
instead of the actual file, so any admin-uploaded image (podcast art,
host photos, etc.) appeared broken in local dev only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Yvx4rPVy2HWe7ubuk3X3J
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-07 14:27:52 -04:00
1 parent 335a9cbe31
commit 31e79ad542
11 files changed
+573 -41

No files matched your search

+125 -1
View File
@@ -383,7 +383,7 @@ function SiteHeader({ content }: { content: SiteContent }) {
const studiesDropdownRef = useRef<HTMLDivElement>(null)
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
const location = useLocation()
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished')
const episodesActive = location.pathname.startsWith('/episodes') || location.pathname.startsWith('/finished') || location.pathname.startsWith('/nuggets')
const studiesActive = location.pathname.startsWith('/study') || location.pathname === '/scripture'
useEffect(() => {
@@ -427,6 +427,7 @@ function SiteHeader({ content }: { content: SiteContent }) {
<div className="nav-dropdown-menu-inner">
<NavLink to="/episodes" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Current Series</NavLink>
<NavLink to="/finished" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Finished Books</NavLink>
<NavLink to="/nuggets" className={({ isActive }) => `nav-dropdown-item${isActive ? ' nav-dropdown-item--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Nuggets</NavLink>
</div>
</div>
</div>
@@ -1863,6 +1864,128 @@ function EpisodesPage({ content }: { content: SiteContent }) {
)
}
interface NuggetsEpisode {
title: string
pubDate: string
link: string
audioUrl: string
description: string
duration: string
episode: string
season: string
}
function NuggetsPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
const nuggets = content.nuggets ?? DEFAULTS.nuggets
usePageMeta(
`${nuggets.title} | ${siteTitle}`,
nuggets.description || nuggets.tagline || `${nuggets.title} — a companion show from ${siteTitle}.`,
)
const [episodes, setEpisodes] = useState<NuggetsEpisode[]>([])
const [loading, setLoading] = useState(true)
const [configured, setConfigured] = useState(true)
useEffect(() => {
fetch('/api/nuggets/episodes/all')
.then(r => r.json())
.then((d: { episodes?: NuggetsEpisode[]; configured?: boolean }) => {
setEpisodes(Array.isArray(d.episodes) ? d.episodes : [])
setConfigured(d.configured !== false)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const [latest, ...rest] = episodes
const activeHosts = (nuggets.hosts ?? []).filter(h => !h.archived)
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Sub-Show" title={nuggets.title} desc={nuggets.tagline} />
{(nuggets.podcastArtUrl || nuggets.description || nuggets.spotifyUrl || nuggets.appleUrl) && (
<section className="section-nuggets-intro" aria-label={`About ${nuggets.title}`}>
<div className="section-inner nuggets-intro-layout">
{nuggets.podcastArtUrl && <img src={nuggets.podcastArtUrl} alt={nuggets.title} className="nuggets-podcast-art" />}
<div className="nuggets-intro-body">
{nuggets.description && <p className="nuggets-description">{nuggets.description}</p>}
{(nuggets.spotifyUrl || nuggets.appleUrl) && (
<div className="nuggets-platform-links">
{nuggets.spotifyUrl && <a href={nuggets.spotifyUrl} target="_blank" rel="noopener noreferrer" className="btn-primary">Listen on Spotify</a>}
{nuggets.appleUrl && <a href={nuggets.appleUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary">Listen on Apple Podcasts</a>}
</div>
)}
</div>
</div>
</section>
)}
<section className="section-nuggets-body" aria-label={`${nuggets.title} hosts and episodes`}>
<div className="section-inner nuggets-body-layout">
<div className="nuggets-body-col nuggets-body-hosts">
{activeHosts.length > 0 && (
<>
<p className="eyebrow">Meet the Hosts</p>
<div className="nuggets-hosts-list">
{activeHosts.map(host => (
<div key={host.id} className="nuggets-host-card">
{host.imageUrl && <img src={host.imageUrl} alt={host.name} className="nuggets-host-card-img" loading="lazy" />}
<h3 className="nuggets-host-card-name">{host.name}</h3>
{host.role && <p className="nuggets-host-card-role">{host.role}</p>}
{host.bio && <p className="nuggets-host-card-bio">{host.bio}</p>}
</div>
))}
</div>
</>
)}
</div>
<div className="nuggets-body-col nuggets-body-episodes">
<p className="eyebrow">Episodes</p>
{loading ? (
<p className="nuggets-episodes-note">Loading episodes…</p>
) : !configured ? (
<p className="nuggets-episodes-note">Episodes are coming soon — check back once the show launches.</p>
) : episodes.length === 0 ? (
<p className="nuggets-episodes-note">No episodes published yet.</p>
) : (
<>
<div className="nuggets-latest-episode">
<p className="nuggets-latest-episode-eyebrow">Latest Episode</p>
<h2 className="nuggets-latest-episode-title">{latest.title}</h2>
{latest.description && <p className="nuggets-latest-episode-desc">{latest.description}</p>}
{latest.audioUrl && <EpisodeAudioPlayer src={latest.audioUrl} title={latest.title} size="full" />}
</div>
{rest.length > 0 && (
<ol className="finished-series-playlist nuggets-episode-list">
{rest.map((ep, i) => (
<li key={ep.audioUrl || i} className="finished-series-episode">
<span className="finished-series-ep-num">{ep.episode || i + 2}</span>
<div className="finished-series-ep-body">
<p className="finished-series-ep-title">{ep.title}</p>
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
{ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
</div>
</li>
))}
</ol>
)}
</>
)}
</div>
</div>
</section>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />
</div>
)
}
function BooksAndResourcesSection({ content }: { content: SiteContent }) {
const books = (content.customLinks ?? []).filter(link => link.placement === 'books')
const recommended = (content.customLinks ?? []).filter(link => link.placement === 'recommended')
@@ -3001,6 +3124,7 @@ export default function App() {
<Route path="/finished/:id" element={<FinishedSeriesPage content={content} />} />
<Route path="/episodes" element={<EpisodesPage content={content} />} />
<Route path="/episodes/:id" element={<EpisodeDetailPage content={content} />} />
<Route path="/nuggets" element={<NuggetsPage content={content} />} />
<Route path="/resources" element={<ResourcesPage content={content} />} />
<Route path="/downloads/:id" element={<DownloadDetailPage content={content} />} />
<Route path="/about" element={<AboutPage content={content} />} />