Add cross-site improvements across three phases

Phase 1 — Quick wins:
- Image lazy-loading on series/resource cards
- Newsletter signup added to Episodes page (before highlights)
- Per-route meta tags via usePageMeta hook (title, og:title, og:description)
- Breadcrumbs on study index, section, and notes pages
- SVG completion checkmark badges on study section list
- Analytics time-range filter (7d / 30d / 90d) in admin panel

Phase 2 — Medium features:
- Related episodes on archived series detail pages
- Resource library two-tier filter (type + tag chips)
- Global search (Fuse.js) moved below sticky header as full-width bar
- Q&A anonymous upvoting with localStorage dedup + admin pin/unpin
- Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics

Phase 3 — Larger features:
- Study section comments (auto-approve for enrolled users, admin moderation panel)
- Study completion certificate (canvas render, PNG download, shareable public URL)
- Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI)
- Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion
- Public certificate route at /certificate/:token with og meta tags
- Comment moderation panel added to admin under Manage > Study Comments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-06-09 09:10:29 -04:00
parent 9ad24df626
commit c4645e3475
24 changed files with 2991 additions and 76 deletions
+215 -24
View File
@@ -8,6 +8,9 @@ import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySect
import { SpotifyIcon } from './icons'
import type { SiteContent, ArchivedSeries, StudyProgram } from './content'
import { DEFAULTS } from './content'
import { usePageMeta } from './hooks/usePageMeta'
import { useGlobalSearch } from './hooks/useGlobalSearch'
import { GlobalSearch } from './components/GlobalSearch'
import './App.css'
const SPOTIFY_EMBED_URL =
@@ -459,6 +462,17 @@ function formatPubDate(raw: string): string {
}
}
function SiteSearchBar({ content }: { content: SiteContent }) {
const { query, setQuery, results } = useGlobalSearch(content)
return (
<div className="site-search-bar">
<div className="site-search-bar-inner">
<GlobalSearch query={query} setQuery={setQuery} results={results} />
</div>
</div>
)
}
function SiteHeader({ content }: { content: SiteContent }) {
const [menuOpen, setMenuOpen] = useState(false)
const spotifyUrl = content.platformSpotifyUrl || '/spotify'
@@ -783,39 +797,72 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
const resources = (content.customLinks ?? []).filter(link => link.placement === 'resources')
const archivedWithResources = (content.archivedSeries ?? []).filter(series => (series.resourceLinks ?? []).length > 0)
const [activeTag, setActiveTag] = useState<string | null>(null)
const [activeType, setActiveType] = useState<'all' | 'resource' | 'series'>('all')
if (resources.length === 0 && archivedWithResources.length === 0) return null
const allTags = Array.from(new Set(resources.flatMap(r => r.tags ?? []))).filter(Boolean)
const filteredResources = activeTag ? resources.filter(r => (r.tags ?? []).includes(activeTag)) : resources
// Filter resources
const filteredResources = resources.filter(r => {
const tagOk = !activeTag || (r.tags ?? []).includes(activeTag)
const typeOk = activeType === 'all' || activeType === 'resource'
return tagOk && typeOk
})
// Filter archived series
const filteredArchived = archivedWithResources.filter(() => {
return activeType === 'all' || activeType === 'series'
})
const hasTypeFilter = resources.length > 0 && archivedWithResources.length > 0
return (
<section className="section-resources section-download-library" aria-label="Download library">
<div className="section-inner">
<div className="download-library-head">
<p className="eyebrow">Download Library</p>
<h2 className="section-heading">More guides, worksheets, and past study downloads.</h2>
<h2 className="section-heading">Guides, worksheets, and past study downloads.</h2>
</div>
{allTags.length > 0 && (
<div className="download-tag-filter">
<button
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(null)}
>
All
</button>
{allTags.map(tag => (
{/* Type + tag filter bar */}
<div className="download-filter-bar">
{hasTypeFilter && (
<div className="download-tag-filter" style={{ marginBottom: allTags.length > 0 ? '0.5rem' : 0 }}>
{(['all', 'resource', 'series'] as const).map(t => (
<button
key={t}
type="button"
className={`download-tag-chip${activeType === t ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveType(t)}
>
{t === 'all' ? 'All types' : t === 'resource' ? 'Study guides' : 'Past series'}
</button>
))}
</div>
)}
{allTags.length > 0 && (activeType === 'all' || activeType === 'resource') && (
<div className="download-tag-filter">
<button
key={tag}
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(prev => prev === tag ? null : tag)}
type="button"
className={`download-tag-chip${activeTag === null ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(null)}
>
{tag}
All topics
</button>
))}
</div>
)}
{allTags.map(tag => (
<button
key={tag}
type="button"
className={`download-tag-chip${activeTag === tag ? ' download-tag-chip--active' : ''}`}
onClick={() => setActiveTag(prev => prev === tag ? null : tag)}
>
{tag}
</button>
))}
</div>
)}
</div>
{filteredResources.length > 0 && (
<div className="download-library-group">
@@ -824,7 +871,7 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
<Link key={resource.id} to={`/downloads/${buildCustomDownloadPageId(resource.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header">
{resource.imageUrl && (
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" />
<img src={resource.imageUrl} alt={resource.label} className="resource-link-image" loading="lazy" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{resource.label}</span>
@@ -841,17 +888,17 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
</div>
)}
{activeTag !== null && filteredResources.length === 0 && (
{activeTag !== null && filteredResources.length === 0 && activeType !== 'series' && (
<p className="download-empty-state">No downloads tagged "{activeTag}".</p>
)}
{archivedWithResources.length > 0 && (
{filteredArchived.length > 0 && (
<div className="download-library-group">
<div className="download-library-group-head">
<h3>Previous Studies</h3>
<p>Downloads from earlier series that you still want available.</p>
<p>Downloads from earlier series.</p>
</div>
{archivedWithResources.map(series => (
{filteredArchived.map(series => (
<div key={series.id} className="archive-series-resources">
<div className="download-library-series-head">
<h4>{series.title || 'Archived Study'}</h4>
@@ -862,7 +909,7 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
<Link key={link.id} to={`/downloads/${buildArchivedDownloadPageId(series.id, link.id)}`} className="resource-download-card resource-download-card--link">
<div className="resource-download-header">
{series.imageUrl && (
<img src={series.imageUrl} alt={series.title || 'Archived study'} className="resource-link-image" />
<img src={series.imageUrl} alt={series.title || 'Archived study'} className="resource-link-image" loading="lazy" />
)}
<div className="resource-download-meta">
<span className="resource-link-label">{link.label || series.title || 'Download Resource'}</span>
@@ -882,10 +929,43 @@ function DownloadLibrarySection({ content }: { content: SiteContent }) {
)
}
function useEpisodesForSeries(seriesId: string | null, archivedSeries: ArchivedSeries[]) {
const [episodes, setEpisodes] = useState<Episode[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!seriesId) return
const series = archivedSeries.find(s => s.id === seriesId)
if (!series?.episodeRange) return
setLoading(true)
fetch('/api/episodes/all')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { episodes?: Episode[] }) => {
const { from, to } = series.episodeRange!
const filtered = (data.episodes ?? []).filter(ep => {
const num = parseInt(ep.episode, 10)
return !isNaN(num) && num >= from && num <= to
})
setEpisodes(filtered)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [seriesId])
return { episodes, loading }
}
function DownloadDetailPage({ content }: { content: SiteContent }) {
const { id } = useParams<{ id: string }>()
const resource = resolveDownloadPageResource(content, id)
// Resolve related series for archived resources
const archivedSeriesId = id?.startsWith('archived--') ? id.split('--')[1] : null
const relatedSeries = archivedSeriesId ? (content.archivedSeries ?? []).find(s => s.id === archivedSeriesId) : null
const { episodes: relatedEpisodes, loading: epsLoading } = useEpisodesForSeries(archivedSeriesId, content.archivedSeries ?? [])
const spotifyUrl = content.platformSpotifyUrl || '/episodes'
if (!resource) {
return (
<main className="thanks-page" aria-label="Download not found">
@@ -928,6 +1008,40 @@ function DownloadDetailPage({ content }: { content: SiteContent }) {
)}
<Link to="/resources" className="btn-secondary">Back to Downloads</Link>
</div>
{relatedSeries && (relatedEpisodes.length > 0 || epsLoading) && (
<div className="download-related-episodes">
<h2 className="download-related-heading">
Episodes from {relatedSeries.title}
</h2>
{epsLoading ? (
<p style={{ color: '#7a7060', fontSize: '0.9rem' }}>Loading episodes</p>
) : (
<div className="download-related-list">
{relatedEpisodes.slice(0, 10).map((ep, idx) => (
<a
key={idx}
href={ep.link || spotifyUrl}
target="_blank"
rel="noreferrer"
className="download-related-ep"
>
<div className="download-related-ep-meta">
{ep.episode && <span className="episode-number">Ep. {ep.episode}</span>}
{ep.pubDate && <span className="episode-date">{formatPubDate(ep.pubDate)}</span>}
</div>
<p className="download-related-ep-title">{ep.title}</p>
</a>
))}
{relatedEpisodes.length > 10 && (
<Link to="/episodes" className="btn-secondary" style={{ marginTop: '0.5rem', display: 'inline-block' }}>
View all {relatedEpisodes.length} episodes
</Link>
)}
</div>
)}
</div>
)}
</div>
</main>
)
@@ -1175,6 +1289,7 @@ function LandingPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<CustomBlocksSection content={content} page="homepage" />
{/* ── HERO ── */}
@@ -1417,6 +1532,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<section className="section-player">
<div className="section-inner" style={{ textAlign: 'center', padding: '4rem 1rem' }}>
<h2>Episode not found</h2>
@@ -1435,6 +1551,7 @@ function EpisodeDetailPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar 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>
@@ -1494,6 +1611,11 @@ function EpisodesPage({ content }: { content: SiteContent }) {
const [allEpisodes, setAllEpisodes] = useState<Episode[]>([])
const [loading, setLoading] = useState(true)
const [failed, setFailed] = useState(false)
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Episodes | ${siteTitle}`,
content.episodesSeoIntro || 'Browse all episodes of Verse by Verse with Nate — expository Bible teaching, one verse at a time.',
)
useEffect(() => {
fetch('/api/episodes/all')
@@ -1589,6 +1711,7 @@ function EpisodesPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<section className="section-player" aria-label="Current series episodes">
<div className="section-inner">
@@ -1669,6 +1792,7 @@ function EpisodesPage({ content }: { content: SiteContent }) {
)
})}
<HomepageNewsletterSection />
<PodcastHighlightsSection content={content} />
<CustomBlocksSection content={content} page="episodes" />
<SiteFooter content={content} />
@@ -1678,9 +1802,15 @@ function EpisodesPage({ content }: { content: SiteContent }) {
}
function ResourcesPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Resources & Downloads | ${siteTitle}`,
'Study guides, sermon notes, and free resources from Verse by Verse with Nate.',
)
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<StudyGuideSection content={content} />
<DownloadLibrarySection content={content} />
<CustomBlocksSection content={content} page="downloads" />
@@ -1692,9 +1822,15 @@ function ResourcesPage({ content }: { content: SiteContent }) {
}
function AboutPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`About | ${siteTitle}`,
content.aboutNate || 'Learn about Verse by Verse with Nate — expository Bible teaching from Nate Emmert.',
)
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<AboutSection content={content} />
<CustomBlocksSection content={content} page="about" />
<SiteFooter content={content} />
@@ -1707,6 +1843,7 @@ function ContactPage({ content }: { content: SiteContent }) {
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<ContactSection content={content} />
<CustomBlocksSection content={content} page="contact" />
<SiteFooter content={content} />
@@ -1925,6 +2062,58 @@ function LegalPage({ title, body }: { title: string; body: string[] }) {
)
}
function PublicCertificatePage() {
const { token } = useParams<{ token: string }>()
const [cert, setCert] = useState<{ studyTitle: string; displayName: string; issuedAt: string } | null>(null)
const [status, setStatus] = useState<'loading' | 'not-found' | 'ready'>('loading')
useEffect(() => {
if (!token) { setStatus('not-found'); return }
fetch(`/api/public/certificate/${encodeURIComponent(token)}`)
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { setCert(data); setStatus('ready') })
.catch(() => setStatus('not-found'))
}, [token])
usePageMeta(
cert ? `Certificate of Completion ${cert.studyTitle}` : 'Certificate',
cert ? `${cert.displayName} completed ${cert.studyTitle}` : undefined,
)
if (status === 'loading') {
return <main className="thanks-page"><div className="thanks-card"><p>Loading certificate…</p></div></main>
}
if (status === 'not-found' || !cert) {
return (
<main className="thanks-page">
<div className="thanks-card">
<h1>Certificate Not Found</h1>
<p>This certificate link is invalid or has been removed.</p>
<Link to="/" className="btn-primary">Back to Site</Link>
</div>
</main>
)
}
return (
<main className="thanks-page public-cert-page" aria-label="Certificate of Completion">
<div className="thanks-card public-cert-card">
<p className="eyebrow">Verse by Verse with Nate</p>
<h1 className="public-cert-title">Certificate of Completion</h1>
<div className="public-cert-divider" />
<p className="public-cert-label">This certifies that</p>
<p className="public-cert-name">{cert.displayName}</p>
<p className="public-cert-label">has successfully completed</p>
<p className="public-cert-study">{cert.studyTitle}</p>
<p className="public-cert-date">
Awarded on {new Date(cert.issuedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</p>
<Link to="/" className="btn-primary" style={{ marginTop: '2rem' }}>Visit Verse by Verse with Nate</Link>
</div>
</main>
)
}
function AdminShell({ content, onSave }: { content: SiteContent; onSave: (c: SiteContent) => void }) {
const [status, setStatus] = useState<'checking' | 'authenticated' | 'unauthenticated' | 'misconfigured'>('checking')
const [adminContent, setAdminContent] = useState<SiteContent>(content)
@@ -2203,6 +2392,7 @@ function StudyRouteFrame({ content, child }: { content: SiteContent; child: Reac
return (
<>
<SiteHeader content={content} />
<SiteSearchBar content={content} />
{child}
<SiteFooter content={content} />
</>
@@ -2303,6 +2493,7 @@ export default function App() {
<Route path="/thanks" element={<ThankYouPage />} />
<Route path="/subscribe" element={<SubscribePage />} />
<Route path="/subscribe/thanks" element={<SubscribeThankYouPage />} />
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
<Route path="/preview" element={<PreviewPage />} />
<Route