Make all site content editable from admin; add per-page content blocks

This commit is contained in:
nmemmert
2026-05-05 16:17:23 -04:00
parent 79eb72635e
commit 7c02062663
11 changed files with 1762 additions and 2502 deletions
+274 -188
View File
@@ -4,18 +4,12 @@ import AdminPage from './AdminPage'
import QASection from './components/QASection'
import ContactForm from './components/ContactForm'
import { FacebookIcon, SpotifyIcon, YouTubeIcon, AmazonMusicIcon } from './icons'
import type { SiteContent } from './content'
import type { SiteContent, ArchivedSeries } from './content'
import { DEFAULTS } from './content'
import './App.css'
const SPOTIFY_SHOW_URL = '/spotify'
const SPOTIFY_EMBED_URL =
'https://open.spotify.com/embed/show/0Gq1TzoJOdReSZ1gYQi8Xl?utm_source=generator&theme=0'
const SPOTIFY_CREATOR_URL = 'https://creators.spotify.com/pod/profile/nmemmert/'
const APPLE_PODCASTS_URL = '/apple'
const YOUTUBE_URL = 'https://www.youtube.com/@blackzebraem5558'
const AMAZON_MUSIC_URL = '/amazon'
const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate'
const CONSENT_KEY = 'vbn_analytics_consent_choice'
function StudyDownloadForm({ buttonText = 'Download Guide' }: { buttonText?: string }) {
@@ -265,7 +259,7 @@ function resolveDownloadPageResource(content: SiteContent, pageId: string | unde
return null
}
function AnalyticsConsentBanner() {
function AnalyticsConsentBanner({ content }: { content: SiteContent }) {
const [choice, setChoice] = useState<'unknown' | 'accepted' | 'declined'>(() => {
const saved = localStorage.getItem(CONSENT_KEY)
if (saved === 'accepted' || saved === 'declined') return saved
@@ -304,9 +298,7 @@ function AnalyticsConsentBanner() {
return (
<div className="consent-banner" role="region" aria-label="Analytics consent">
<p>
We use optional analytics cookies to measure visits and location trends for site improvement.
</p>
<p>{content.cookieBannerText || 'We use optional analytics cookies to measure visits and location trends for site improvement.'}</p>
<div className="consent-actions">
<button type="button" className="btn-primary" onClick={accept}>Accept</button>
<button type="button" className="btn-secondary" onClick={decline}>Decline</button>
@@ -332,79 +324,9 @@ function formatPubDate(raw: string): string {
}
}
function LatestEpisodesList() {
const [episodes, setEpisodes] = useState<Episode[]>([])
const [loading, setLoading] = useState(true)
const [failed, setFailed] = useState(false)
useEffect(() => {
fetch('/api/episodes')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('fetch failed'))))
.then((data: { episodes: Episode[] }) => {
if (data.episodes.length === 0) setFailed(true)
else setEpisodes(data.episodes)
setLoading(false)
})
.catch(() => {
setFailed(true)
setLoading(false)
})
}, [])
if (loading) {
return (
<div className="episode-list-loading">
{[1, 2, 3].map(i => <div key={i} className="episode-skeleton" aria-hidden="true" />)}
</div>
)
}
if (failed) {
return (
<div className="embed-wrap">
<iframe
title="Verse by Verse with Nate"
src={SPOTIFY_EMBED_URL}
width="100%"
height="352"
frameBorder="0"
allowFullScreen
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
style={{ borderRadius: '14px' }}
/>
</div>
)
}
return (
<div className="episode-list">
{episodes.map((ep, idx) => (
<a
key={idx}
href={ep.link || SPOTIFY_SHOW_URL}
target="_blank"
rel="noreferrer"
className="episode-card"
>
<div className="episode-card-meta">
{ep.episode && <span className="episode-number">Ep. {ep.episode}</span>}
{ep.pubDate && <span className="episode-date">{formatPubDate(ep.pubDate)}</span>}
{ep.duration && <span className="episode-duration">{ep.duration}</span>}
</div>
<h3 className="episode-title">{ep.title}</h3>
{ep.description && <p className="episode-desc">{ep.description}</p>}
<span className="episode-listen-cta">
<SpotifyIcon /> Listen
</span>
</a>
))}
</div>
)
}
function SiteHeader() {
function SiteHeader({ content }: { content: SiteContent }) {
const [menuOpen, setMenuOpen] = useState(false)
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
return (
<header className="site-header">
@@ -426,12 +348,12 @@ function SiteHeader() {
<NavLink to="/about" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>About</NavLink>
<NavLink to="/contact" className={({ isActive }) => `header-nav-link${isActive ? ' header-nav-link--active' : ''}`} onClick={() => setMenuOpen(false)}>Contact</NavLink>
<a
href={SPOTIFY_SHOW_URL}
href={spotifyUrl}
target="_blank"
rel="noreferrer"
className="header-cta"
>
Follow on Spotify
{content.headerFollowLabel || 'Follow on Spotify'}
</a>
</nav>
</div>
@@ -440,28 +362,36 @@ function SiteHeader() {
}
function SiteFooter({ content }: { content: SiteContent }) {
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
const appleUrl = content.platformAppleUrl || '/apple'
const youtubeUrl = content.platformYoutubeUrl || 'https://www.youtube.com/@blackzebraem5558'
const amazonUrl = content.platformAmazonUrl || '/amazon'
const creatorUrl = content.platformCreatorUrl || 'https://creators.spotify.com/pod/profile/nmemmert/'
const facebookUrl = content.platformFacebookUrl || 'https://facebook.com/versebyversewithnate'
return (
<footer className="site-footer">
<p className="footer-ornament"> &nbsp; &nbsp; </p>
<p className="footer-title">Verse by Verse with Nate</p>
<p className="footer-sub">A Journey Through Scripture</p>
<p className="footer-contact">
Contact:{' '}
<a href="mailto:hello@versebyversewithnate.us">hello@versebyversewithnate.us</a>
</p>
<p className="footer-response">We usually reply within 48 hours.</p>
<p className="footer-title">{content.footerTitle || 'Verse by Verse with Nate'}</p>
<p className="footer-sub">{content.footerSubtitle || 'A Journey Through Scripture'}</p>
{content.footerEmail && (
<p className="footer-contact">
Contact:{' '}
<a href={`mailto:${content.footerEmail}`}>{content.footerEmail}</a>
</p>
)}
<nav className="footer-links" aria-label="Footer links">
<a href={SPOTIFY_SHOW_URL} target="_blank" rel="noreferrer">Spotify</a>
<a href={spotifyUrl} target="_blank" rel="noreferrer">Spotify</a>
<span aria-hidden="true">·</span>
<a href={APPLE_PODCASTS_URL} target="_blank" rel="noreferrer">Apple Podcasts</a>
<a href={appleUrl} target="_blank" rel="noreferrer">Apple Podcasts</a>
<span aria-hidden="true">·</span>
<a href={YOUTUBE_URL} target="_blank" rel="noreferrer">YouTube</a>
<a href={youtubeUrl} target="_blank" rel="noreferrer">YouTube</a>
<span aria-hidden="true">·</span>
<a href={AMAZON_MUSIC_URL} target="_blank" rel="noreferrer">Amazon Music</a>
<a href={amazonUrl} target="_blank" rel="noreferrer">Amazon Music</a>
<span aria-hidden="true">·</span>
<a href={SPOTIFY_CREATOR_URL} target="_blank" rel="noreferrer">Creator Profile</a>
<a href={creatorUrl} target="_blank" rel="noreferrer">Creator Profile</a>
<span aria-hidden="true">·</span>
<a href={FACEBOOK_URL} target="_blank" rel="noreferrer">Facebook</a>
<a href={facebookUrl} target="_blank" rel="noreferrer">Facebook</a>
{(content.customLinks ?? []).filter(l => l.placement === 'footer').flatMap(l => [
<span key={`sep-${l.id}`} aria-hidden="true">·</span>,
<a key={l.id} href={l.url} target="_blank" rel="noreferrer">{l.label}</a>,
@@ -472,34 +402,40 @@ function SiteFooter({ content }: { content: SiteContent }) {
<span aria-hidden="true">·</span>
<Link to="/terms">Terms</Link>
</nav>
<p className="footer-copy">© 2026 Nate Emmert · Made with faith.</p>
<p className="footer-privacy">
Privacy: with consent, analytics may store masked IP-based location data (country/state/county/city) and returning visitor activity.
</p>
<p className="footer-copy">{content.footerCopyright || '© 2026 Nate Emmert · Made with faith.'}</p>
{content.footerPrivacyNote && (
<p className="footer-privacy">{content.footerPrivacyNote}</p>
)}
</footer>
)
}
function PlatformButtons({ content }: { content: SiteContent }) {
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
const youtubeUrl = content.platformYoutubeUrl || 'https://www.youtube.com/@blackzebraem5558'
const amazonUrl = content.platformAmazonUrl || '/amazon'
const facebookUrl = content.platformFacebookUrl || 'https://facebook.com/versebyversewithnate'
const appleUrl = content.platformAppleUrl || '/apple'
return (
<div className="platform-buttons">
<a href={SPOTIFY_SHOW_URL} target="_blank" rel="noreferrer" className="platform-btn spotify-btn">
<a href={spotifyUrl} target="_blank" rel="noreferrer" className="platform-btn spotify-btn">
<SpotifyIcon />
Listen on Spotify
</a>
<a href={YOUTUBE_URL} target="_blank" rel="noreferrer" className="platform-btn youtube-btn">
<a href={youtubeUrl} target="_blank" rel="noreferrer" className="platform-btn youtube-btn">
<YouTubeIcon />
YouTube
</a>
<a href={AMAZON_MUSIC_URL} target="_blank" rel="noreferrer" className="platform-btn amazon-btn">
<a href={amazonUrl} target="_blank" rel="noreferrer" className="platform-btn amazon-btn">
<AmazonMusicIcon />
Amazon Music
</a>
<a href={FACEBOOK_URL} target="_blank" rel="noreferrer" className="platform-btn facebook-btn">
<a href={facebookUrl} target="_blank" rel="noreferrer" className="platform-btn facebook-btn">
<FacebookIcon />
Facebook
</a>
<a href={APPLE_PODCASTS_URL} target="_blank" rel="noreferrer" className="platform-btn apple-btn">
<a href={appleUrl} target="_blank" rel="noreferrer" className="platform-btn apple-btn">
<img src="/images/apple-podcasts-badge.svg" alt="Listen on Apple Podcasts" className="apple-badge" />
</a>
{(content.customLinks ?? []).filter(l => l.placement === 'platforms').map(l => (
@@ -519,14 +455,14 @@ function AboutSection({ content }: { content: SiteContent }) {
<img src={content.aboutPhotoUrl || '/images/nate-photo.jpeg'} alt="Nate Emmert" />
<div className="about-photo-divider" aria-hidden="true" />
<div className="about-nate-copy">
<p className="eyebrow">About Nate</p>
<p className="eyebrow">{content.aboutEyebrow || 'About Nate'}</p>
<div className="about-nate-rule" aria-hidden="true" />
<p>{content.aboutNate}</p>
<Link to="/episodes" className="about-nate-cta">Listen Now </Link>
<Link to="/episodes" className="about-nate-cta">{content.aboutListenBtnLabel || 'Listen Now ↓'}</Link>
</div>
</div>
<div className="about-text">
<p className="eyebrow">About the Show</p>
<p className="eyebrow">{content.aboutShowEyebrow || 'About the Show'}</p>
<h2>{content.aboutShowHeading}</h2>
<p>{content.aboutShowP1}</p>
<p>{content.aboutShowP2}</p>
@@ -585,51 +521,59 @@ function ContactSection({ content }: { content: SiteContent }) {
<div className="section-inner contact-inner">
<div className="contact-copy">
<div className="contact-card">
<p className="eyebrow">Get in Touch</p>
<h2>Contact Nate</h2>
<p className="eyebrow">{content.contactEyebrow || 'Get in Touch'}</p>
<h2>{content.contactHeading || 'Contact Nate'}</h2>
<div className="contact-profile">
<img
src={content.contactPhotoUrl || '/images/nate-contact-photo.png'}
alt="Nate"
alt={content.contactName || 'Nate'}
className="contact-profile-photo"
/>
<div className="contact-profile-meta">
<p className="contact-profile-name">Nate</p>
<p className="contact-profile-role">Bible teacher · Lynchburg, VA</p>
<p className="contact-profile-name">{content.contactName || 'Nate'}</p>
<p className="contact-profile-role">{content.contactRole || 'Bible teacher · Lynchburg, VA'}</p>
</div>
</div>
<blockquote className="contact-quote">
<p>"I read every message - nothing blesses me more than hearing how God's Word is at work in your life."</p>
</blockquote>
{content.contactQuote && (
<blockquote className="contact-quote">
<p>"{content.contactQuote}"</p>
</blockquote>
)}
<p className="contact-intro">
Ask a Bible question, share a testimony, or request a topic for a future episode.
</p>
{content.contactIntro && (
<p className="contact-intro">{content.contactIntro}</p>
)}
<div className="contact-points" aria-label="Contact response details">
<div className="contact-point">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>I read every message personally</span>
</div>
<div className="contact-point">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>Response within 48 hours</span>
</div>
{content.contactPoint1 && (
<div className="contact-point">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
<span>{content.contactPoint1}</span>
</div>
)}
{content.contactPoint2 && (
<div className="contact-point">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span>{content.contactPoint2}</span>
</div>
)}
</div>
<div className="contact-scripture">
<p>
"Your word is a lamp to my feet and a light to my path." -{' '}
<span className="contact-scripture-ref">Psalm 119:105</span>
</p>
</div>
{content.contactVerse && (
<div className="contact-scripture">
<p>
"{content.contactVerse}" -{' '}
<span className="contact-scripture-ref">{content.contactVerseRef}</span>
</p>
</div>
)}
</div>
</div>
<ContactForm />
@@ -800,10 +744,15 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
)
}
function CustomBlocksSection({ content }: { content: SiteContent }) {
function CustomBlocksSection({ content, page }: { content: SiteContent; page: string }) {
const blocks = (content.customBlocks ?? []).filter(block => {
const blockPage = block.page ?? 'downloads'
return blockPage === page
})
if (blocks.length === 0) return null
return (
<>
{(content.customBlocks ?? []).map(block => (
{blocks.map(block => (
<section key={block.id} className="section-custom-block" aria-label={block.heading}>
<div className="section-inner custom-block-inner">
<h2>{block.heading}</h2>
@@ -818,7 +767,8 @@ function CustomBlocksSection({ content }: { content: SiteContent }) {
function LandingPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader />
<SiteHeader content={content} />
<CustomBlocksSection content={content} page="homepage" />
{/* ── HERO ── */}
<section className="hero" aria-label="Show introduction">
@@ -840,15 +790,16 @@ function LandingPage({ content }: { content: SiteContent }) {
<p className="tagline">{content.heroTagline}</p>
<div className="hero-ctas">
<a
href={SPOTIFY_SHOW_URL}
href={content.platformSpotifyUrl || '/spotify'}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
<SpotifyIcon />
Listen on Spotify
{content.heroBtnSpotify || 'Listen on Spotify'}
</a>
<Link to="/episodes" className="btn-secondary">Explore Episodes </Link>
<Link to="/episodes" className="btn-secondary">{content.heroBtnEpisodes || 'Explore Episodes ↓'}</Link>
<Link to="/start-here" className="btn-start-here">{content.heroBtnStartHere || 'New here? Start here →'}</Link>
</div>
</div>
</section>
@@ -871,15 +822,15 @@ function LandingPage({ content }: { content: SiteContent }) {
<h3>{content.seriesTitle}</h3>
<p>{content.seriesDescription}</p>
<a
href={content.seriesListenUrl || SPOTIFY_SHOW_URL}
href={content.seriesListenUrl || content.platformSpotifyUrl || '/spotify'}
target="_blank"
rel="noreferrer"
className="btn-primary"
>
<SpotifyIcon />
Listen to Series
{content.seriesListenBtnLabel || 'Listen to Series'}
</a>
<Link to="/resources" className="btn-secondary">View Downloads</Link>
<Link to="/resources" className="btn-secondary">{content.seriesDownloadsBtnLabel || 'View Downloads'}</Link>
</div>
</article>
</div>
@@ -889,37 +840,23 @@ function LandingPage({ content }: { content: SiteContent }) {
<section className="section-home-jump" aria-label="Homepage navigation">
<div className="section-inner">
<div className="home-jump-head">
<p className="eyebrow">Where to Next</p>
<h2 className="section-heading">Choose the page you need.</h2>
<p className="eyebrow">{content.whereToNextEyebrow || 'Where to Next'}</p>
<h2 className="section-heading">{content.whereToNextHeading || 'Choose the page you need.'}</h2>
</div>
<div className="home-jump-grid">
<Link to="/episodes" className="home-jump-card">
<h3>Episodes</h3>
<p>Listen to latest episodes and platform links.</p>
</Link>
<Link to="/resources" className="home-jump-card">
<h3>Downloads</h3>
<p>Main guide, extra downloads, and past study files.</p>
</Link>
<Link to="/questions" className="home-jump-card">
<h3>Q&amp;A</h3>
<p>Browse Bible questions and approved answers.</p>
</Link>
<Link to="/about" className="home-jump-card">
<h3>About</h3>
<p>Learn about Nate and the mission of the show.</p>
</Link>
<Link to="/contact" className="home-jump-card">
<h3>Contact</h3>
<p>Submit a Bible question, testimony, or topic.</p>
</Link>
{(content.whereToNextCards ?? []).map(card => (
<Link key={card.id} to={card.path} className="home-jump-card">
<h3>{card.title}</h3>
<p>{card.description}</p>
</Link>
))}
</div>
</div>
</section>
<SiteFooter content={content} />
<AnalyticsConsentBanner />
<AnalyticsConsentBanner content={content} />
</div>
)
}
@@ -931,7 +868,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
if (!episode) {
return (
<div className="site">
<SiteHeader />
<SiteHeader content={content} />
<section className="section-player">
<div className="section-inner" style={{ textAlign: 'center', padding: '4rem 1rem' }}>
<h2>Episode not found</h2>
@@ -940,7 +877,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
</div>
</section>
<SiteFooter content={content} />
<AnalyticsConsentBanner />
<AnalyticsConsentBanner content={content} />
</div>
)
}
@@ -949,7 +886,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader />
<SiteHeader content={content} />
<section className="section-episode-detail" aria-label={episode.title}>
<div className="section-inner">
<Link to="/episodes" className="episode-detail-back">← Back to Episodes</Link>
@@ -1000,28 +937,154 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
</div>
</section>
<SiteFooter content={content} />
<AnalyticsConsentBanner />
<AnalyticsConsentBanner content={content} />
</div>
)
}
function EpisodesPage({ content }: { content: SiteContent }) {
const [allEpisodes, setAllEpisodes] = useState<Episode[]>([])
const [loading, setLoading] = useState(true)
const [failed, setFailed] = useState(false)
useEffect(() => {
fetch('/api/episodes/all')
.then(r => (r.ok ? r.json() : Promise.reject(new Error('fetch failed'))))
.then((data: { episodes: Episode[] }) => {
if (data.episodes.length === 0) setFailed(true)
else setAllEpisodes(data.episodes)
setLoading(false)
})
.catch(() => {
setFailed(true)
setLoading(false)
})
}, [])
const archivedSeries = content.archivedSeries ?? []
const archivedEpisodeNums = new Set<number>(
archivedSeries.flatMap(s => {
if (!s.episodeRange) return []
const nums: number[] = []
for (let i = s.episodeRange.from; i <= s.episodeRange.to; i++) nums.push(i)
return nums
})
)
const currentEpisodes = allEpisodes.filter(ep => {
const num = parseInt(ep.episode, 10)
return isNaN(num) || !archivedEpisodeNums.has(num)
})
function episodesForSeries(series: ArchivedSeries) {
if (!series.episodeRange) return []
const { from, to } = series.episodeRange
return allEpisodes.filter(ep => {
const num = parseInt(ep.episode, 10)
return !isNaN(num) && num >= from && num <= to
})
}
function renderEpisodeCards(episodes: Episode[]) {
return (
<div className="episode-list">
{episodes.map((ep, idx) => (
<a
key={idx}
href={ep.link || content.platformSpotifyUrl || '/spotify'}
target="_blank"
rel="noreferrer"
className="episode-card"
>
<div className="episode-card-meta">
{ep.episode && <span className="episode-number">Ep. {ep.episode}</span>}
{ep.pubDate && <span className="episode-date">{formatPubDate(ep.pubDate)}</span>}
{ep.duration && <span className="episode-duration">{ep.duration}</span>}
</div>
<h3 className="episode-title">{ep.title}</h3>
{ep.description && <p className="episode-desc">{ep.description}</p>}
<span className="episode-listen-cta">
<SpotifyIcon /> Listen →
</span>
</a>
))}
</div>
)
}
const spotifyFallback = (
<div className="embed-wrap">
<iframe
title="Verse by Verse with Nate"
src={SPOTIFY_EMBED_URL}
width="100%"
height="352"
frameBorder="0"
allowFullScreen
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
style={{ borderRadius: '14px' }}
/>
</div>
)
const loadingSkeleton = (
<div className="episode-list-loading">
{[1, 2, 3].map(i => <div key={i} className="episode-skeleton" aria-hidden="true" />)}
</div>
)
return (
<div className="site">
<SiteHeader />
<section className="section-player" aria-label="Podcast episodes">
<SiteHeader content={content} />
<section className="section-player" aria-label="Current series episodes">
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament">✦</span> Latest Episodes{' '}
<span className="ornament">✦</span> {content.seriesLabel || 'Now Playing'}{' '}
<span className="ornament">✦</span>
</h2>
<LatestEpisodesList />
{content.seriesTitle && <p className="episode-series-subtitle">{content.seriesTitle}</p>}
{loading
? loadingSkeleton
: failed || currentEpisodes.length === 0
? spotifyFallback
: renderEpisodeCards(currentEpisodes)}
<PlatformButtons content={content} />
</div>
</section>
{archivedSeries.map(series => {
const episodes = episodesForSeries(series)
return (
<section key={series.id} className="section-player" aria-label={series.title}>
<div className="section-inner">
<h2 className="section-heading">
<span className="ornament">✦</span> {series.label || 'Archived Study'}{' '}
<span className="ornament">✦</span>
</h2>
{series.title && <p className="episode-series-subtitle">{series.title}</p>}
{series.description && <p className="episode-series-desc">{series.description}</p>}
{loading
? loadingSkeleton
: episodes.length > 0
? renderEpisodeCards(episodes)
: series.listenUrl
? (
<a href={series.listenUrl} target="_blank" rel="noreferrer" className="btn-primary" style={{ display: 'inline-block', marginBottom: '1.5rem' }}>
Listen to Full Series →
</a>
)
: null}
</div>
</section>
)
})}
<PodcastHighlightsSection content={content} />
<CustomBlocksSection content={content} page="episodes" />
<SiteFooter content={content} />
<AnalyticsConsentBanner />
<AnalyticsConsentBanner content={content} />
</div>
)
}
@@ -1029,12 +1092,12 @@ function EpisodesPage({ content }: { content: SiteContent }) {
function ResourcesPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader />
<SiteHeader content={content} />
<StudyGuideSection content={content} />
<DownloadLibrarySection content={content} />
<CustomBlocksSection content={content} />
<CustomBlocksSection content={content} page="downloads" />
<SiteFooter content={content} />
<AnalyticsConsentBanner />
<AnalyticsConsentBanner content={content} />
</div>
)
}
@@ -1042,10 +1105,11 @@ function ResourcesPage({ content }: { content: SiteContent }) {
function AboutPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader />
<SiteHeader content={content} />
<AboutSection content={content} />
<CustomBlocksSection content={content} page="about" />
<SiteFooter content={content} />
<AnalyticsConsentBanner />
<AnalyticsConsentBanner content={content} />
</div>
)
}
@@ -1053,10 +1117,11 @@ function AboutPage({ content }: { content: SiteContent }) {
function ContactPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader />
<SiteHeader content={content} />
<ContactSection content={content} />
<CustomBlocksSection content={content} page="contact" />
<SiteFooter content={content} />
<AnalyticsConsentBanner />
<AnalyticsConsentBanner content={content} />
</div>
)
}
@@ -1447,6 +1512,7 @@ function QuestionsPage() {
function PreviewPage() {
const [content, setContent] = useState<SiteContent | null>(null)
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [previewView, setPreviewView] = useState<string>('homepage')
useEffect(() => {
fetch('/api/admin-content?source=draft')
@@ -1464,6 +1530,20 @@ function PreviewPage() {
})
}, [])
// Listen for live content updates + view from the admin parent frame
useEffect(() => {
function handleMessage(e: MessageEvent) {
if (e.origin !== window.location.origin) return
if (e.data?.type === 'admin-preview-content' && e.data.content) {
setContent({ ...DEFAULTS, ...e.data.content })
setStatus('ready')
if (e.data.view) setPreviewView(e.data.view)
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
if (status === 'loading') {
return (
<main className="thanks-page" aria-label="Draft preview loading">
@@ -1489,6 +1569,12 @@ function PreviewPage() {
)
}
if (previewView === 'start-here') return <StartHerePage content={content} />
if (previewView === 'about') return <AboutPage content={content} />
if (previewView === 'contact') return <ContactPage content={content} />
if (previewView === 'current-series' || previewView === 'archived-series' || previewView === 'episode-highlights') {
return <EpisodesPage content={content} />
}
return <LandingPage content={content} />
}