diff --git a/public/images/nate-contact-photo.png b/public/images/nate-contact-photo.png new file mode 100644 index 0000000..0e0b2dd Binary files /dev/null and b/public/images/nate-contact-photo.png differ diff --git a/server.js b/server.js index ff0382b..7f12c71 100644 --- a/server.js +++ b/server.js @@ -1404,6 +1404,70 @@ app.delete('/api/admin-questions/:id', requireAdminAuth, (req, res) => { res.json({ ok: true }) }) +// ── Episodes (RSS feed proxy) ────────────────────────────────────────────── +const RSS_FEED_URL = 'https://anchor.fm/nmemmert/podcast/rss' +let episodesCache = null +let episodesCacheAt = 0 +const EPISODES_CACHE_TTL = 30 * 60 * 1000 // 30 minutes + +function extractCdata(raw) { + const cdata = /^$/.exec(raw.trim()) + return cdata ? cdata[1].trim() : raw.trim() +} + +function parseRssItems(xml, limit = 6) { + const items = [] + const itemRegex = /([\s\S]*?)<\/item>/g + let match + while ((match = itemRegex.exec(xml)) !== null && items.length < limit) { + const block = match[1] + const titleRaw = /([\s\S]*?)<\/title>/.exec(block)?.[1] ?? '' + const title = extractCdata(titleRaw) + if (!title) continue + + const pubDate = (/<pubDate>([\s\S]*?)<\/pubDate>/.exec(block)?.[1] ?? '').trim() + const guidRaw = /<guid[^>]*>([\s\S]*?)<\/guid>/.exec(block)?.[1] ?? '' + const guid = extractCdata(guidRaw) + const enclosureUrl = /<enclosure[^>]+url="([^"]+)"/.exec(block)?.[1] ?? '' + const link = guid.startsWith('http') ? guid : enclosureUrl + const descRaw = /<description>([\s\S]*?)<\/description>/.exec(block)?.[1] ?? '' + const descText = extractCdata(descRaw).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim() + const duration = (/<itunes:duration>([\s\S]*?)<\/itunes:duration>/.exec(block)?.[1] ?? '').trim() + const episode = (/<itunes:episode>([\s\S]*?)<\/itunes:episode>/.exec(block)?.[1] ?? '').trim() + items.push({ + title, + pubDate, + link, + description: descText.slice(0, 220) + (descText.length > 220 ? '…' : ''), + duration, + episode, + }) + } + return items +} + +app.get('/api/episodes', async (_req, res) => { + const now = Date.now() + if (episodesCache && (now - episodesCacheAt) < EPISODES_CACHE_TTL) { + return res.json({ episodes: episodesCache }) + } + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 8000) + const response = await fetch(RSS_FEED_URL, { signal: controller.signal }) + clearTimeout(timeout) + if (!response.ok) throw new Error(`RSS fetch failed: ${response.status}`) + const xml = await response.text() + const episodes = parseRssItems(xml, 6) + episodesCache = episodes + episodesCacheAt = now + res.json({ episodes }) + } catch (err) { + console.error('[episodes] RSS fetch error:', err.message) + res.json({ episodes: episodesCache ?? [] }) + } +}) + app.use(express.static(DIST_DIR)) app.use(async (_req, res) => { diff --git a/src/App.css b/src/App.css index 986c3e8..a0366b8 100644 --- a/src/App.css +++ b/src/App.css @@ -267,6 +267,119 @@ overflow: hidden; } +/* ── Episode List ── */ +.episode-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 2.5rem; +} + +.episode-card { + display: block; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(201, 168, 76, 0.2); + border-left: 3px solid var(--brand-gold); + border-radius: 10px; + padding: 1.1rem 1.4rem; + text-decoration: none; + transition: background 180ms, border-color 180ms, transform 180ms; +} + +.episode-card:hover { + background: rgba(201, 168, 76, 0.07); + border-color: rgba(201, 168, 76, 0.45); + transform: translateX(3px); +} + +.episode-card-meta { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 0.35rem; + flex-wrap: wrap; +} + +.episode-number { + font-family: var(--brand-font-body); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--brand-gold); +} + +.episode-date, +.episode-duration { + font-family: var(--brand-font-body); + font-size: 0.78rem; + color: var(--brand-muted); + opacity: 0.7; +} + +.episode-date::before { + content: '·'; + margin-right: 0.85rem; + opacity: 0.5; +} + +.episode-duration::before { + content: '·'; + margin-right: 0.85rem; + opacity: 0.5; +} + +.episode-title { + font-family: var(--brand-font-heading); + font-size: 1.05rem; + font-weight: 600; + color: var(--brand-warm-white); + margin: 0 0 0.35rem; + line-height: 1.35; +} + +.episode-desc { + font-family: var(--brand-font-body); + font-size: 0.88rem; + font-weight: 300; + line-height: 1.55; + color: var(--brand-muted); + margin: 0 0 0.6rem; + opacity: 0.85; +} + +.episode-listen-cta { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-family: var(--brand-font-body); + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--brand-gold); + text-transform: uppercase; +} + +.episode-list-loading { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 2.5rem; +} + +.episode-skeleton { + height: 100px; + border-radius: 10px; + background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.4s infinite; +} + +@keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + .platform-buttons { display: flex; gap: 1rem; @@ -607,10 +720,11 @@ .qr-frame { display: inline-block; - padding: 12px; - background: #f5eed8; - border-radius: 8px; + padding: 16px 18px; + background: #ffffff; + border-radius: 24px; position: relative; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35); } .qr-code { @@ -734,28 +848,120 @@ align-items: start; } -.contact-copy .section-heading { - text-align: left; - margin-bottom: 1rem; +.contact-card { + background: #111111; + border: 1px solid rgba(201, 168, 76, 0.2); + border-radius: 16px; + padding: 2.3rem; + max-width: 500px; } -.contact-copy p { +.contact-card .eyebrow { + margin: 0 0 0.45rem; +} + +.contact-card h2 { + margin: 0 0 1.75rem; + font-family: var(--brand-font-heading); + color: var(--brand-warm-white); + font-size: clamp(1.45rem, 2vw, 1.9rem); + line-height: 1.3; + font-weight: 600; +} + +.contact-profile { + display: flex; + align-items: flex-start; + gap: 1rem; + margin-bottom: 1.75rem; +} + +.contact-profile-photo { + width: 72px; + height: 72px; + border-radius: 999px; + object-fit: cover; + object-position: top; + border: 2px solid var(--brand-gold); + flex-shrink: 0; +} + +.contact-profile-name { + margin: 0 0 0.15rem; font-family: var(--brand-font-body); - font-weight: 300; - font-size: 1.08rem; - line-height: 1.7; - color: var(--brand-muted); - margin: 0; - padding: 0; -} - -.contact-copy .contact-highlight { - font-size: clamp(1.25rem, 2.2vw, 1.65rem); - line-height: 1.35; + font-size: 1.45rem; font-weight: 600; color: var(--brand-warm-white); - margin: 0 0 0.6rem; - text-wrap: balance; +} + +.contact-profile-role { + margin: 0; + font-family: var(--brand-font-body); + font-size: 0.95rem; + color: var(--brand-gold); +} + +.contact-quote { + border-left: 2px solid var(--brand-gold); + padding-left: 1rem; + margin: 0 0 1.75rem; +} + +.contact-quote p { + margin: 0; + font-family: var(--brand-font-body); + font-size: 1.35rem; + font-style: italic; + font-weight: 500; + color: #d4c4a0; + line-height: 1.6; +} + +.contact-intro { + margin: 0 0 1rem; + font-family: var(--brand-font-body); + font-size: 1rem; + line-height: 1.6; + color: #9a9994; +} + +.contact-points { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.contact-point { + display: flex; + align-items: center; + gap: 0.65rem; + color: #8f8e89; + font-family: var(--brand-font-body); + font-size: 0.92rem; +} + +.contact-point svg { + color: var(--brand-gold); + flex: 0 0 auto; +} + +.contact-scripture { + margin-top: 1.75rem; + padding-top: 1.5rem; + border-top: 1px solid #2c2116; +} + +.contact-scripture p { + margin: 0; + font-family: var(--brand-font-body); + font-size: 0.88rem; + font-style: italic; + line-height: 1.6; + color: #6d6b66; +} + +.contact-scripture-ref { + white-space: nowrap; } .contact-form { @@ -1111,19 +1317,22 @@ .guide-inner { display: grid; - grid-template-columns: 100px 1fr; + grid-template-columns: 160px 1fr; gap: 3rem; - align-items: center; + align-items: start; } -.guide-icon { - width: 100px; +.guide-cover-art { flex-shrink: 0; + width: 160px; } -.guide-icon svg { +.guide-cover-img { width: 100%; height: auto; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55); + display: block; } .guide-text h2 { @@ -2290,8 +2499,9 @@ text-align: center; } - .guide-icon { + .guide-cover-art { margin: 0 auto; + width: 140px; } .study-download-grid { @@ -2319,7 +2529,7 @@ .contact-inner { grid-template-columns: 1fr; - text-align: center; + gap: 1.5rem; } .chatbot-feature-inner { @@ -2335,8 +2545,13 @@ justify-content: center; } - .contact-copy .section-heading { - text-align: center; + .contact-card { + max-width: 100%; + padding: 1.45rem; + } + + .contact-quote p { + font-size: 1.2rem; } .header-ornament { diff --git a/src/App.tsx b/src/App.tsx index 8fd789d..c1e496f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -640,6 +640,94 @@ function ShareShowButton() { ) } +interface Episode { + title: string + pubDate: string + link: string + description: string + duration: string + episode: string +} + +function formatPubDate(raw: string): string { + try { + return new Date(raw).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) + } catch { + return raw + } +} + +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 LandingPage({ content }: { content: SiteContent }) { const archivedSeries = content.archivedSeries ?? [] const [seriesView, setSeriesView] = useState<'current' | 'archive'>('current') @@ -739,19 +827,7 @@ function LandingPage({ content }: { content: SiteContent }) { <span className="ornament">✦</span> Latest Episodes{' '} <span className="ornament">✦</span> </h2> - <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> + <LatestEpisodesList /> <div className="platform-buttons"> <a href={SPOTIFY_SHOW_URL} target="_blank" rel="noreferrer" className="platform-btn spotify-btn"> <SpotifyIcon /> @@ -977,14 +1053,12 @@ function LandingPage({ content }: { content: SiteContent }) { {/* ── STUDY GUIDE ── */} <section className="section-guide" aria-label="Companion study guide"> <div className="section-inner guide-inner"> - <div className="guide-icon" aria-hidden="true"> - <svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"> - <rect x="8" y="6" width="36" height="48" rx="3" fill="#c8860a" opacity="0.15" stroke="#c8860a" strokeWidth="1.5"/> - <rect x="14" y="6" width="36" height="48" rx="3" fill="#0f0f0f" stroke="#c8860a" strokeWidth="1.5"/> - <line x1="22" y1="22" x2="42" y2="22" stroke="#c8860a" strokeWidth="1.5" strokeLinecap="round"/> - <line x1="22" y1="30" x2="42" y2="30" stroke="#c8860a" strokeWidth="1.5" strokeLinecap="round" opacity="0.6"/> - <line x1="22" y1="38" x2="34" y2="38" stroke="#c8860a" strokeWidth="1.5" strokeLinecap="round" opacity="0.4"/> - </svg> + <div className="guide-cover-art"> + <img + src={content.seriesImageUrl || '/images/titus-cover.png'} + alt={content.seriesTitle} + className="guide-cover-img" + /> </div> <div className="guide-text"> <p className="eyebrow">Free Download</p> @@ -1044,14 +1118,53 @@ function LandingPage({ content }: { content: SiteContent }) { <section className="section-contact" id="contact" aria-label="Contact form"> <div className="section-inner contact-inner"> <div className="contact-copy"> - <p className="eyebrow">Get in Touch</p> - <h2 className="section-heading"> - <span className="ornament">✦</span> Contact Nate <span className="ornament">✦</span> - </h2> - <p className="contact-highlight"> - Ask a Bible question, share a testimony, or request a topic for a future episode. - </p> - <p>We usually respond within 48 hours.</p> + <div className="contact-card"> + <p className="eyebrow">Get in Touch</p> + <h2>Contact Nate</h2> + + <div className="contact-profile"> + <img + src="/images/nate-contact-photo.png" + alt="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> + </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> + + <p className="contact-intro"> + Ask a Bible question, share a testimony, or request a topic for a future episode. + </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> + </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> + </div> </div> <ContactForm /> </div>