Episodes + Finished Books redesign: current-series hero, searchable episode browser with series chips, shared episode rows; finished-books library with search, testament/sort filters, featured card, episode counts and badges; v2.31.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03f46ae683
commit
f1577e9d8a
4 files changed
+295
-59
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"version": "2.30.0",
|
||||
"version": "2.31.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "siteforge",
|
||||
"version": "2.30.0",
|
||||
"version": "2.31.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1135.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.30.0",
|
||||
"version": "2.31.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+42
@@ -13786,3 +13786,45 @@ a.study-track-card:hover {
|
||||
.si-verse-btn { margin-left: 0; }
|
||||
}
|
||||
|
||||
|
||||
/* ── Episodes hub & Finished Books library ── */
|
||||
.ep-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
grid-template-areas: "art body" "player player";
|
||||
gap: 1.5rem 2rem;
|
||||
align-items: center;
|
||||
max-width: 1000px;
|
||||
}
|
||||
.ep-hero-art { grid-area: art; width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 12px; border: 1px solid rgba(201, 168, 76, 0.25); }
|
||||
.ep-hero-body { grid-area: body; }
|
||||
.ep-hero-player { grid-area: player; }
|
||||
.ep-hero-title { font-family: var(--brand-font-heading); font-size: 1.7rem; color: #e8d9b5; margin: 0.2rem 0 0.6rem; }
|
||||
.ep-hero-desc { color: var(--brand-muted); line-height: 1.6; margin: 0; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.section-episode-browser { padding: 3rem 0 2rem; }
|
||||
.ep-season-chips { margin: 0 0 1.25rem; }
|
||||
.ep-season-link { margin: -0.5rem 0 1rem; font-size: 0.9rem; }
|
||||
.ep-season-link a { color: var(--brand-gold); text-decoration: none; }
|
||||
.section-episode-headliner { padding: 0 0 3rem; }
|
||||
|
||||
.finished-books-toolbar { display: flex; flex-direction: column; gap: 0.9rem; margin-bottom: 0.5rem; }
|
||||
.finished-books-toolbar .finished-series-filter { margin: 0; }
|
||||
.finished-book-card-facts { font-size: 0.82rem; color: var(--brand-gold); opacity: 0.85; margin: 0; }
|
||||
.finished-book-card-badges { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.6rem; }
|
||||
.finished-book-badge { font-size: 0.72rem; letter-spacing: 0.04em; padding: 0.15rem 0.6rem; border: 1px solid rgba(201, 168, 76, 0.3); border-radius: 999px; color: var(--brand-muted); }
|
||||
.finished-book-card--featured { grid-column: 1 / -1; flex-direction: row; }
|
||||
.finished-book-card--featured .finished-book-card-img-wrap { flex: 0 0 280px; }
|
||||
.finished-book-card--featured .finished-book-card-img { height: 100%; aspect-ratio: auto; }
|
||||
.finished-book-card--featured .finished-book-card-body { justify-content: center; padding: 1.5rem 1.8rem; }
|
||||
.finished-book-card--featured .finished-book-card-title { font-size: 1.8rem; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ep-hero { grid-template-columns: 96px 1fr; gap: 1rem; }
|
||||
.ep-hero-title { font-size: 1.3rem; }
|
||||
.ep-hero-desc { -webkit-line-clamp: 3; font-size: 0.9rem; }
|
||||
.ep-season-chips { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 0.2rem; }
|
||||
.finished-book-card--featured { flex-direction: column; }
|
||||
.finished-book-card--featured .finished-book-card-img-wrap { flex-basis: auto; }
|
||||
.finished-book-card--featured .finished-book-card-img { aspect-ratio: 16 / 9; height: auto; }
|
||||
}
|
||||
.ep-hero-player { margin-bottom: 0.5rem; }
|
||||
+250
-56
@@ -1612,28 +1612,150 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
|
||||
)
|
||||
}
|
||||
|
||||
function useEpisodesForBook(season: number) {
|
||||
const [episodes, setEpisodes] = useState<{ title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string }[]>([])
|
||||
type PodEpisode = { title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string; pubDate: string }
|
||||
|
||||
function useAllEpisodes() {
|
||||
const [episodes, setEpisodes] = useState<PodEpisode[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
fetch('/api/episodes/all')
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then((data: { episodes?: { title: string; episode: string; season: string; duration: string; audioUrl: string; link: string; description: string }[] }) => {
|
||||
const filtered = (data.episodes ?? [])
|
||||
.filter(ep => ep.season === String(season))
|
||||
.sort((a, b) => parseInt(a.episode) - parseInt(b.episode))
|
||||
setEpisodes(filtered)
|
||||
})
|
||||
.then((data: { episodes?: PodEpisode[] }) => setEpisodes(data.episodes ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [season])
|
||||
}, [])
|
||||
return { episodes, loading }
|
||||
}
|
||||
|
||||
function useEpisodesForBook(season: number) {
|
||||
const { episodes: all, loading } = useAllEpisodes()
|
||||
const episodes = useMemo(
|
||||
() => all.filter(ep => ep.season === String(season)).sort((a, b) => parseInt(a.episode) - parseInt(b.episode)),
|
||||
[all, season],
|
||||
)
|
||||
return { episodes, loading }
|
||||
}
|
||||
|
||||
function durationSeconds(d: string) {
|
||||
if (!d) return 0
|
||||
if (/^\d+$/.test(d)) return parseInt(d, 10)
|
||||
const parts = d.split(':').map(Number)
|
||||
if (parts.some(n => Number.isNaN(n))) return 0
|
||||
return parts.reduce((acc, n) => acc * 60 + n, 0)
|
||||
}
|
||||
|
||||
function formatRuntime(sec: number) {
|
||||
if (sec <= 0) return ''
|
||||
const h = Math.floor(sec / 3600)
|
||||
const m = Math.round((sec % 3600) / 60)
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
function formatEpisodeDate(pubDate: string) {
|
||||
const d = new Date(pubDate)
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
// One playlist row: number, title, date/duration, and a Play button that opens the audio player in place.
|
||||
function EpisodeRow({ ep, label, isOpen, onToggle }: { ep: PodEpisode; label: string | number; isOpen: boolean; onToggle: () => void }) {
|
||||
const date = formatEpisodeDate(ep.pubDate)
|
||||
const meta = [date, ep.duration].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<li className={`finished-series-episode${isOpen ? ' finished-series-episode--open' : ''}`}>
|
||||
<span className="finished-series-ep-num">{label}</span>
|
||||
<div className="finished-series-ep-body">
|
||||
<div className="finished-series-ep-row">
|
||||
<div>
|
||||
<p className="finished-series-ep-title">{ep.title}</p>
|
||||
{meta && <p className="finished-series-ep-meta">{meta}</p>}
|
||||
</div>
|
||||
{ep.audioUrl && (
|
||||
<button type="button" className="finished-series-ep-play" aria-expanded={isOpen} onClick={onToggle}>
|
||||
{isOpen ? 'Hide player' : '▶ Play'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Maps a finished-book title ("Titus", "Study of 1 John") to its canonical Bible book id.
|
||||
function bibleBookIdForTitle(title: string): string | null {
|
||||
const t = title.toLowerCase()
|
||||
const hits = BIBLE_BOOKS.filter(b => t.includes(b.name.toLowerCase())).sort((a, b) => b.name.length - a.name.length)
|
||||
return hits[0]?.id ?? null
|
||||
}
|
||||
|
||||
function FinishedBooksPage({ content }: { content: SiteContent }) {
|
||||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||||
usePageMeta(`Finished Books | ${siteTitle}`, 'Completed series from Verse by Verse with Nate — browse episode playlists for past book studies.')
|
||||
const books = content.finishedBooks ?? []
|
||||
const { episodes } = useAllEpisodes()
|
||||
const [query, setQuery] = useState('')
|
||||
const [testament, setTestament] = useState<'all' | 'ot' | 'nt'>('all')
|
||||
const [sort, setSort] = useState<'recent' | 'canon'>('recent')
|
||||
|
||||
const statsBySeason = useMemo(() => {
|
||||
const map = new Map<number, { count: number; seconds: number }>()
|
||||
for (const ep of episodes) {
|
||||
const season = parseInt(ep.season, 10)
|
||||
if (Number.isNaN(season)) continue
|
||||
const cur = map.get(season) ?? { count: 0, seconds: 0 }
|
||||
cur.count += 1
|
||||
cur.seconds += durationSeconds(ep.duration)
|
||||
map.set(season, cur)
|
||||
}
|
||||
return map
|
||||
}, [episodes])
|
||||
|
||||
const canonIndex = (title: string) => {
|
||||
const id = bibleBookIdForTitle(title)
|
||||
const i = id ? BIBLE_BOOKS.findIndex(b => b.id === id) : -1
|
||||
return i < 0 ? 999 : i
|
||||
}
|
||||
const needle = query.trim().toLowerCase()
|
||||
const filtering = Boolean(needle) || testament !== 'all'
|
||||
const visible = books
|
||||
.filter(book => {
|
||||
if (needle && !`${book.title} ${book.description}`.toLowerCase().includes(needle)) return false
|
||||
if (testament !== 'all') {
|
||||
const i = canonIndex(book.title)
|
||||
if (i === 999 || (testament === 'ot') !== (i < 39)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => sort === 'canon' ? canonIndex(a.title) - canonIndex(b.title) : b.season - a.season)
|
||||
const showFeatured = !filtering && sort === 'recent' && visible.length > 2
|
||||
|
||||
const renderCard = (book: (typeof books)[number], featured: boolean) => {
|
||||
const stats = statsBySeason.get(book.season)
|
||||
const facts = [stats ? `${stats.count} episodes` : '', stats ? formatRuntime(stats.seconds) : ''].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<Link key={book.id} to={`/finished/${book.id}`} className={`finished-book-card${featured ? ' finished-book-card--featured' : ''}`}>
|
||||
{book.imageUrl && (
|
||||
<div className="finished-book-card-img-wrap">
|
||||
<img src={book.imageUrl} alt={book.title} className="finished-book-card-img" loading="lazy" />
|
||||
</div>
|
||||
)}
|
||||
<div className="finished-book-card-body">
|
||||
<p className="finished-book-card-season">{featured ? `Most recently finished · Season ${book.season}` : `Season ${book.season}`}</p>
|
||||
<h2 className="finished-book-card-title">{book.title}</h2>
|
||||
{facts && <p className="finished-book-card-facts">{facts}</p>}
|
||||
{book.description && <p className="finished-book-card-desc">{book.description}</p>}
|
||||
<div className="finished-book-card-badges">
|
||||
{book.downloadUrl && <span className="finished-book-badge">⬇ Album</span>}
|
||||
{book.studySlug && <span className="finished-book-badge">📖 Study</span>}
|
||||
{book.guide?.title && <span className="finished-book-badge">📄 Guide</span>}
|
||||
</div>
|
||||
<span className="finished-book-card-cta">View Playlist →</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const pill = (active: boolean) => `si-pill${active ? ' si-pill--on' : ''}`
|
||||
|
||||
return (
|
||||
<div className="site">
|
||||
@@ -1654,24 +1776,38 @@ function FinishedBooksPage({ content }: { content: SiteContent }) {
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="finished-books-grid">
|
||||
{books.map(book => (
|
||||
<Link key={book.id} to={`/finished/${book.id}`} className="finished-book-card">
|
||||
{book.imageUrl && (
|
||||
<div className="finished-book-card-img-wrap">
|
||||
<img src={book.imageUrl} alt={book.title} className="finished-book-card-img" />
|
||||
{book.downloadUrl && <span className="finished-book-card-dl-badge" title="Download available">⬇</span>}
|
||||
<>
|
||||
{books.length > 3 && (
|
||||
<div className="finished-books-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
className="finished-series-filter"
|
||||
placeholder="Search finished books…"
|
||||
aria-label="Search finished books"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="si-filters">
|
||||
<div className="si-pills" role="group" aria-label="Testament">
|
||||
<button type="button" className={pill(testament === 'all')} onClick={() => setTestament('all')}>All</button>
|
||||
<button type="button" className={pill(testament === 'ot')} onClick={() => setTestament('ot')}>Old Testament</button>
|
||||
<button type="button" className={pill(testament === 'nt')} onClick={() => setTestament('nt')}>New Testament</button>
|
||||
</div>
|
||||
<div className="si-pills" role="group" aria-label="Sort order">
|
||||
<button type="button" className={pill(sort === 'recent')} onClick={() => setSort('recent')}>Most recent</button>
|
||||
<button type="button" className={pill(sort === 'canon')} onClick={() => setSort('canon')}>Bible order</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="finished-book-card-body">
|
||||
<p className="finished-book-card-season">Season {book.season}</p>
|
||||
<h2 className="finished-book-card-title">{book.title}</h2>
|
||||
{book.description && <p className="finished-book-card-desc">{book.description}</p>}
|
||||
<span className="finished-book-card-cta">View Playlist →</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{visible.length === 0 ? (
|
||||
<p className="finished-books-empty">No finished books match your filters.</p>
|
||||
) : (
|
||||
<div className="finished-books-grid">
|
||||
{visible.map((book, i) => renderCard(book, showFeatured && i === 0))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1683,7 +1819,7 @@ function FinishedBooksPage({ content }: { content: SiteContent }) {
|
||||
const FINISHED_PLAYLIST_PREVIEW_COUNT = 10
|
||||
const FINISHED_PLAYLIST_FILTER_THRESHOLD = 12
|
||||
|
||||
type FinishedBookEpisode = { title: string; episode: string; duration: string; audioUrl: string; description: string }
|
||||
type FinishedBookEpisode = PodEpisode
|
||||
|
||||
// One section with two tabs instead of two full-length lists stacked on top of each other.
|
||||
// "Highlights" are the hand-picked episodes (with notes); "All Episodes" is the complete
|
||||
@@ -1781,31 +1917,7 @@ function FinishedSeriesEpisodes({ highlights, episodes, loading }: {
|
||||
<ol className="finished-series-playlist">
|
||||
{shownEpisodes.map((ep, i) => {
|
||||
const key = ep.audioUrl || `${ep.episode}-${i}`
|
||||
const isOpen = openKey === key
|
||||
return (
|
||||
<li key={key} className={`finished-series-episode${isOpen ? ' finished-series-episode--open' : ''}`}>
|
||||
<span className="finished-series-ep-num">{ep.episode || i + 1}</span>
|
||||
<div className="finished-series-ep-body">
|
||||
<div className="finished-series-ep-row">
|
||||
<div>
|
||||
<p className="finished-series-ep-title">{ep.title}</p>
|
||||
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
|
||||
</div>
|
||||
{ep.audioUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="finished-series-ep-play"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setOpenKey(isOpen ? null : key)}
|
||||
>
|
||||
{isOpen ? 'Hide player' : '▶ Play'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
return <EpisodeRow key={key} ep={ep} label={ep.episode || i + 1} isOpen={openKey === key} onToggle={() => setOpenKey(openKey === key ? null : key)} />
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
@@ -2036,12 +2148,36 @@ function LatestEpisodePlayer({ content }: { content: SiteContent }) {
|
||||
)
|
||||
}
|
||||
|
||||
const EPISODES_PREVIEW_COUNT = 8
|
||||
|
||||
function EpisodesPage({ content }: { content: SiteContent }) {
|
||||
const siteTitle = content.seo?.title || DEFAULTS.seo.title
|
||||
usePageMeta(
|
||||
`Episodes | ${siteTitle}`,
|
||||
content.episodesSeoIntro || 'Browse all episodes of Verse by Verse with Nate — expository Bible teaching, one verse at a time.',
|
||||
)
|
||||
const { episodes, loading } = useAllEpisodes()
|
||||
const [query, setQuery] = useState('')
|
||||
const [season, setSeason] = useState('all')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [openKey, setOpenKey] = useState<string | null>(null)
|
||||
|
||||
const finishedBooks = [...(content.finishedBooks ?? [])].sort((a, b) => b.season - a.season)
|
||||
const seasonChips = [
|
||||
...(content.seriesSeason ? [{ key: String(content.seriesSeason), label: 'Current series' }] : []),
|
||||
...finishedBooks.map(book => ({ key: String(book.season), label: book.title })),
|
||||
]
|
||||
const countFor = (key: string) => episodes.filter(ep => ep.season === key).length
|
||||
|
||||
const needle = query.trim().toLowerCase()
|
||||
const matching = episodes.filter(ep => {
|
||||
if (season !== 'all' && ep.season !== season) return false
|
||||
return !needle || `${ep.episode} ${ep.title}`.toLowerCase().includes(needle)
|
||||
})
|
||||
const showAll = expanded || needle.length > 0
|
||||
const shown = showAll ? matching : matching.slice(0, EPISODES_PREVIEW_COUNT)
|
||||
const activeBook = finishedBooks.find(book => String(book.season) === season)
|
||||
const pill = (active: boolean) => `si-pill${active ? ' si-pill--on' : ''}`
|
||||
|
||||
return (
|
||||
<div className="site">
|
||||
@@ -2049,22 +2185,80 @@ function EpisodesPage({ content }: { content: SiteContent }) {
|
||||
<SiteSearchBar content={content} />
|
||||
<PageBanner eyebrow="Episodes" title="Podcast Episodes" desc={content.episodesSeoIntro || 'Expository Bible teaching — one verse at a time.'} />
|
||||
|
||||
<section className="section-player" aria-label="Current series episodes">
|
||||
<div className="section-inner">
|
||||
<section className="section-player" aria-label="Current series">
|
||||
<div className="section-inner ep-hero">
|
||||
<p className="episode-seo-intro" aria-hidden="true">
|
||||
{content.episodesSeoIntro || 'Verse by Verse with Nate is an expository Bible teaching podcast where we go through Scripture one verse at a time. Each episode digs into the text carefully and practically, helping you understand what the Bible says, what it means, and how to live it out. New episodes released regularly — subscribe on Spotify or your favorite podcast platform.'}
|
||||
</p>
|
||||
<div className="embed-wrap">
|
||||
{content.seriesImageUrl && <img src={content.seriesImageUrl} alt={content.seriesTitle} className="ep-hero-art" />}
|
||||
<div className="ep-hero-body">
|
||||
<p className="finished-book-card-season">Current series{content.seriesSeason ? ` · Season ${content.seriesSeason}` : ''}</p>
|
||||
<h2 className="ep-hero-title">{content.seriesTitle}</h2>
|
||||
{content.seriesDescription && <p className="ep-hero-desc">{content.seriesDescription}</p>}
|
||||
</div>
|
||||
<div className="ep-hero-player">
|
||||
<LatestEpisodePlayer content={content} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-inner">
|
||||
<PlatformButtons content={content} />
|
||||
<HeadlinerWidget />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<HomepageNewsletterSection />
|
||||
<section className="section-episode-browser" aria-label="Browse episodes">
|
||||
<div className="section-inner">
|
||||
<h2 className="section-heading"><span className="ornament">✦</span> Browse Episodes <span className="ornament">✦</span></h2>
|
||||
<input
|
||||
type="search"
|
||||
className="finished-series-filter"
|
||||
placeholder="Search episodes by number or title…"
|
||||
aria-label="Search episodes"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
{seasonChips.length > 0 && (
|
||||
<div className="si-pills ep-season-chips" role="group" aria-label="Filter by series">
|
||||
<button type="button" className={pill(season === 'all')} onClick={() => { setSeason('all'); setExpanded(false) }}>
|
||||
All{!loading && <span>{episodes.length}</span>}
|
||||
</button>
|
||||
{seasonChips.map(chip => (
|
||||
<button key={chip.key} type="button" className={pill(season === chip.key)} onClick={() => { setSeason(chip.key); setExpanded(false) }}>
|
||||
{chip.label}{!loading && <span>{countFor(chip.key)}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{activeBook && (
|
||||
<p className="ep-season-link"><Link to={`/finished/${activeBook.id}`}>Open the full {activeBook.title} playlist →</Link></p>
|
||||
)}
|
||||
{loading ? (
|
||||
<p className="finished-books-empty">Loading episodes…</p>
|
||||
) : matching.length === 0 ? (
|
||||
<p className="finished-books-empty">{needle ? `No episodes match “${query.trim()}”.` : 'No episodes found.'}</p>
|
||||
) : (
|
||||
<ol className="finished-series-playlist">
|
||||
{shown.map((ep, i) => {
|
||||
const key = ep.audioUrl || `${ep.episode}-${i}`
|
||||
return <EpisodeRow key={key} ep={ep} label={ep.episode || '•'} isOpen={openKey === key} onToggle={() => setOpenKey(openKey === key ? null : key)} />
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
{!needle && matching.length > EPISODES_PREVIEW_COUNT && (
|
||||
<div className="highlight-more">
|
||||
<button type="button" className="btn-secondary" onClick={() => setExpanded(v => !v)}>
|
||||
{expanded ? 'Show fewer' : `Show all ${matching.length} episodes`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PodcastHighlightsSection content={content} />
|
||||
<CustomBlocksSection content={content} page="episodes" />
|
||||
<HomepageNewsletterSection />
|
||||
<section className="section-episode-headliner" aria-label="Podcast notifications">
|
||||
<div className="section-inner"><HeadlinerWidget /></div>
|
||||
</section>
|
||||
<SiteFooter content={content} />
|
||||
<AnalyticsConsentBanner content={content} />
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user