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
+1017 -1445
View File
File diff suppressed because it is too large Load Diff
+346
View File
@@ -208,6 +208,24 @@
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
}
.btn-start-here {
font-family: var(--brand-font-body);
font-size: 0.85rem;
color: rgba(201, 168, 76, 0.7);
text-decoration: none;
letter-spacing: 0.06em;
padding: 0.25rem 0;
border-bottom: 1px solid rgba(201, 168, 76, 0.3);
transition: color 200ms, border-color 200ms;
white-space: nowrap;
}
.btn-start-here:hover {
color: var(--brand-gold);
border-color: var(--brand-gold);
}
.btn-primary {
@@ -285,6 +303,25 @@
padding: 5rem 0;
}
.episode-series-subtitle {
text-align: center;
color: var(--brand-gold);
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.04em;
margin: -0.75rem 0 1.75rem;
opacity: 0.85;
}
.episode-series-desc {
text-align: center;
color: rgba(255, 255, 255, 0.6);
font-size: 0.95rem;
max-width: 56ch;
margin: 0 auto 2rem;
line-height: 1.6;
}
.embed-wrap {
margin-bottom: 2rem;
border-radius: 14px;
@@ -3463,6 +3500,315 @@
margin: 0 auto;
}
/* ── Admin Shell Layout ── */
.admin-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
background: #070707;
}
.admin-topbar {
position: sticky;
top: 0;
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
background: #070707;
border-bottom: 1px solid rgba(201,168,76,0.2);
padding: 0.65rem 1.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.admin-topbar-brand {
display: flex;
align-items: center;
gap: 0.6rem;
}
.admin-topbar-ornament {
color: var(--brand-gold);
font-size: 1rem;
}
.admin-topbar-title {
font-family: var(--brand-font-heading);
font-size: 1.1rem;
color: var(--brand-warm-white);
letter-spacing: 0.03em;
}
.admin-topbar-sub {
font-size: 0.78rem;
color: rgba(201,168,76,0.5);
letter-spacing: 0.05em;
}
.admin-topbar-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.admin-topbar-meta {
font-size: 0.78rem;
color: rgba(240,234,216,0.45);
}
.admin-layout {
display: flex;
flex: 1;
min-height: 0;
}
/* ── Sidebar ── */
.admin-sidebar {
width: 210px;
min-width: 210px;
background: #09090a;
border-right: 1px solid rgba(201,168,76,0.1);
padding: 1.25rem 0 2rem;
overflow-y: auto;
position: sticky;
top: 51px; /* matches topbar height */
height: calc(100vh - 51px);
}
.admin-sidebar-meta-links {
display: flex;
flex-direction: column;
padding: 0 1.1rem 1rem;
border-bottom: 1px solid rgba(201,168,76,0.08);
margin-bottom: 1rem;
gap: 0.25rem;
}
.admin-meta-link {
display: block;
background: none;
border: none;
color: rgba(240,234,216,0.45);
font-size: 0.82rem;
padding: 0.25rem 0;
cursor: pointer;
text-decoration: none;
text-align: left;
transition: color 150ms;
}
.admin-meta-link:hover {
color: var(--brand-warm-white);
}
.admin-nav-group {
margin-bottom: 1.25rem;
}
.admin-nav-label {
display: block;
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(201,168,76,0.45);
padding: 0 1.1rem;
margin-bottom: 0.25rem;
}
.admin-nav-item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
border-left: 2px solid transparent;
color: rgba(240,234,216,0.6);
padding: 0.4rem 1.1rem;
font-size: 0.88rem;
cursor: pointer;
transition: color 150ms, background 150ms;
line-height: 1.4;
}
.admin-nav-item:hover {
color: var(--brand-warm-white);
background: rgba(201,168,76,0.06);
}
.admin-nav-item--active {
color: var(--brand-gold) !important;
background: rgba(201,168,76,0.1);
border-left-color: var(--brand-gold);
}
/* ── Content Panel ── */
.admin-panel {
flex: 1;
padding: 2rem 2.5rem;
overflow-y: auto;
min-width: 0;
}
.admin-panel-section {
max-width: 740px;
}
.admin-panel-head {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(201,168,76,0.12);
}
.admin-panel-head h2 {
font-family: var(--brand-font-heading);
font-size: 1.6rem;
color: var(--brand-warm-white);
margin: 0 0 0.35rem;
}
.admin-panel-head p {
color: rgba(240,234,216,0.5);
font-size: 0.88rem;
margin: 0;
}
.admin-panel-subhead {
margin: 2rem 0 1rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid rgba(201,168,76,0.08);
}
.admin-panel-subhead h3 {
font-family: var(--brand-font-heading);
font-size: 1.05rem;
color: var(--brand-gold);
margin: 0;
letter-spacing: 0.04em;
}
.admin-panel-subhead p {
color: rgba(240,234,216,0.45);
font-size: 0.82rem;
margin: 0.25rem 0 0;
}
.admin-section-header {
margin: 2rem 0 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(201,168,76,0.08);
}
.admin-section-header h3 {
font-family: var(--brand-font-heading);
font-size: 1.15rem;
color: var(--brand-warm-white);
margin: 0 0 0.25rem;
}
.admin-section-header p {
color: rgba(240,234,216,0.45);
font-size: 0.84rem;
margin: 0;
}
.admin-archive-helper {
background: rgba(201,168,76,0.05);
border: 1px solid rgba(201,168,76,0.15);
border-radius: 6px;
padding: 1.25rem;
margin-bottom: 2rem;
}
.admin-archive-helper h3 {
font-family: var(--brand-font-heading);
font-size: 1rem;
color: var(--brand-gold);
margin: 0 0 0.35rem;
}
.admin-archive-helper p {
color: rgba(240,234,216,0.55);
font-size: 0.85rem;
margin: 0 0 0.75rem;
}
/* ── Preview pane ── */
.admin-layout--split {
overflow: hidden;
}
.admin-layout--split .admin-panel {
max-width: none;
min-width: 320px;
flex: 1 1 40%;
overflow-y: auto;
border-right: 1px solid rgba(201,168,76,0.1);
}
.admin-preview-pane {
flex: 1 1 55%;
min-width: 360px;
display: flex;
flex-direction: column;
background: #fff;
position: sticky;
top: 51px;
height: calc(100vh - 51px);
}
.admin-preview-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.45rem 1rem;
background: #0a0a08;
border-bottom: 1px solid rgba(201,168,76,0.15);
flex-shrink: 0;
}
.admin-preview-label {
font-size: 0.78rem;
font-family: var(--brand-font-body);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--brand-gold);
}
.admin-preview-hint {
font-size: 0.72rem;
color: rgba(240,234,216,0.35);
}
.admin-preview-iframe {
flex: 1;
width: 100%;
border: none;
display: block;
}
.btn-admin-reset--active {
background: rgba(201,168,76,0.15);
color: var(--brand-gold);
border-color: rgba(201,168,76,0.4);
}
@media (max-width: 768px) {
.admin-sidebar {
display: none;
}
.admin-panel {
padding: 1.25rem 1rem;
}
.admin-topbar-sub {
display: none;
}
.admin-preview-pane {
display: none;
}
}
.series-toggle-btn {
border: none;
background: transparent;
+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} />
}
+96
View File
@@ -12,6 +12,14 @@ export interface CustomBlock {
id: string
heading: string
body: string
page?: 'homepage' | 'start-here' | 'episodes' | 'downloads' | 'about' | 'contact' | 'questions'
}
export interface WhereToNextCard {
id: string
title: string
description: string
path: string
}
export interface ArchivedSeriesResourceLink {
@@ -39,6 +47,7 @@ export interface ArchivedSeries {
studyGuideUrl: string
resourceLinks: ArchivedSeriesResourceLink[]
notes: ArchivedSeriesNote[]
episodeRange?: { from: number; to: number }
}
export interface RedirectRule {
@@ -78,8 +87,13 @@ export interface LegalSettings {
}
export interface SiteContent {
// ── Hero ──
eyebrow: string
heroTagline: string
heroBtnSpotify: string
heroBtnEpisodes: string
heroBtnStartHere: string
// ── Start Here ──
startHereHeading: string
startHereIntro: string
startHereStep1Title: string
@@ -91,24 +105,66 @@ export interface SiteContent {
startHereStep3Title: string
startHereStep3Body: string
startHereStep3Cta: string
// ── About ──
aboutEyebrow: string
aboutListenBtnLabel: string
aboutShowEyebrow: string
aboutShowHeading: string
aboutShowP1: string
aboutShowP2: string
aboutNate: string
aboutPhotoUrl: string
aboutVerseArtUrl: string
// ── Contact ──
contactPhotoUrl: string
contactEyebrow: string
contactHeading: string
contactName: string
contactRole: string
contactQuote: string
contactIntro: string
contactPoint1: string
contactPoint2: string
contactVerse: string
contactVerseRef: string
// ── Series ──
seriesLabel: string
seriesTitle: string
seriesDescription: string
seriesImageUrl: string
seriesListenUrl: string
seriesListenBtnLabel: string
seriesDownloadsBtnLabel: string
// ── Downloads ──
studyGuideTitle: string
studyGuideDescription: string
studyGuideDownloadUrl: string
studyGuideUrl: string
// ── Share ──
shareHeading: string
shareP: string
// ── Homepage navigation cards ──
whereToNextEyebrow: string
whereToNextHeading: string
whereToNextCards: WhereToNextCard[]
// ── Global / Footer ──
footerTitle: string
footerSubtitle: string
footerEmail: string
footerCopyright: string
footerPrivacyNote: string
// ── Platform URLs ──
platformSpotifyUrl: string
platformAppleUrl: string
platformYoutubeUrl: string
platformAmazonUrl: string
platformFacebookUrl: string
platformCreatorUrl: string
// ── Header ──
headerFollowLabel: string
// ── Cookie banner ──
cookieBannerText: string
// ── Arrays ──
customLinks: CustomLink[]
customBlocks: CustomBlock[]
archivedSeries: ArchivedSeries[]
@@ -121,6 +177,9 @@ export interface SiteContent {
export const DEFAULTS: SiteContent = {
eyebrow: 'A Journey Through Scripture',
heroTagline: "Exploring God's Word one verse at a time",
heroBtnSpotify: 'Listen on Spotify',
heroBtnEpisodes: 'Explore Episodes ↓',
heroBtnStartHere: 'New here? Start here →',
startHereHeading: 'New Here? Start Here',
startHereIntro:
'If this is your first visit, this path helps you get grounded quickly and make the most of the site.',
@@ -136,6 +195,9 @@ export const DEFAULTS: SiteContent = {
startHereStep3Body:
'Use the contact form to submit your Bible question for future Q&A or an upcoming episode.',
startHereStep3Cta: 'Submit a Question',
aboutEyebrow: 'About Nate',
aboutListenBtnLabel: 'Listen Now ↓',
aboutShowEyebrow: 'About the Show',
aboutShowHeading: 'Depth. Clarity. Application.',
aboutShowP1:
'Verse by Verse with Nate walks through Scripture passage by passage — unpacking the original context, drawing out the meaning, and connecting each verse to how we live today.',
@@ -146,12 +208,24 @@ export const DEFAULTS: SiteContent = {
aboutPhotoUrl: '/images/nate-photo.jpeg',
aboutVerseArtUrl: '/images/hebrews-4-12-verse-art.png',
contactPhotoUrl: '/images/nate-contact-photo.png',
contactEyebrow: 'Get in Touch',
contactHeading: 'Contact Nate',
contactName: 'Nate',
contactRole: 'Bible teacher · Lynchburg, VA',
contactQuote: 'I read every message - nothing blesses me more than hearing how God\'s Word is at work in your life.',
contactIntro: 'Ask a Bible question, share a testimony, or request a topic for a future episode.',
contactPoint1: 'I read every message personally',
contactPoint2: 'Response within 48 hours',
contactVerse: 'Your word is a lamp to my feet and a light to my path.',
contactVerseRef: 'Psalm 119:105',
seriesLabel: 'Now Playing',
seriesTitle: 'Study of Titus: Sound Doctrine',
seriesDescription:
"A deep-dive into Paul's letter to Titus — unpacking what it means to build a church and a life on sound doctrine.",
seriesImageUrl: '/images/titus-cover.png',
seriesListenUrl: '/spotify',
seriesListenBtnLabel: 'Listen to Series',
seriesDownloadsBtnLabel: 'View Downloads',
studyGuideTitle: 'Companion Study Guide',
studyGuideDescription:
'Go deeper in your study with the official Verse by Verse companion guide — now available on Amazon.',
@@ -159,6 +233,28 @@ export const DEFAULTS: SiteContent = {
studyGuideUrl: 'https://a.co/d/01sG2tOJ',
shareHeading: 'Help one more person hear the Word this week.',
shareP: 'Scan the QR code or text the show link to a friend who needs encouragement today.',
whereToNextEyebrow: 'Where to Next',
whereToNextHeading: 'Choose the page you need.',
whereToNextCards: [
{ id: 'episodes', title: 'Episodes', description: 'Listen to latest episodes and platform links.', path: '/episodes' },
{ id: 'downloads', title: 'Downloads', description: 'Main guide, extra downloads, and past study files.', path: '/resources' },
{ id: 'qa', title: 'Q&A', description: 'Browse Bible questions and approved answers.', path: '/questions' },
{ id: 'about', title: 'About', description: 'Learn about Nate and the mission of the show.', path: '/about' },
{ id: 'contact', title: 'Contact', description: 'Submit a Bible question, testimony, or topic.', path: '/contact' },
],
footerTitle: 'Verse by Verse with Nate',
footerSubtitle: 'A Journey Through Scripture',
footerEmail: 'hello@versebyversewithnate.us',
footerCopyright: '© 2026 Nate Emmert · Made with faith.',
footerPrivacyNote: 'Privacy: with consent, analytics may store masked IP-based location data (country/state/county/city) and returning visitor activity.',
platformSpotifyUrl: '/spotify',
platformAppleUrl: '/apple',
platformYoutubeUrl: 'https://www.youtube.com/@blackzebraem5558',
platformAmazonUrl: '/amazon',
platformFacebookUrl: 'https://facebook.com/versebyversewithnate',
platformCreatorUrl: 'https://creators.spotify.com/pod/profile/nmemmert/',
headerFollowLabel: 'Follow on Spotify',
cookieBannerText: 'We use optional analytics cookies to measure visits and location trends for site improvement.',
customLinks: [],
customBlocks: [],
archivedSeries: [],