Make all site content editable from admin; add per-page content blocks
This commit is contained in:
+274
-188
@@ -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">✦ ✦ ✦</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&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} />
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user