Redo /newsletter and /nuggets pages; add Nuggets episode tags with admin editor; v2.37.0
Publish Container / build (push) Successful in 16m20s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-13 13:49:15 -04:00
1 parent ba35ccab19
commit 5d5c026762
9 files changed
+446 -85

No files matched your search

+192 -81
View File
@@ -2373,6 +2373,7 @@ interface NuggetsEpisode {
duration: string
episode: string
season: string
tags?: string[]
}
function NuggetsPage({ content }: { content: SiteContent }) {
@@ -2398,82 +2399,134 @@ function NuggetsPage({ content }: { content: SiteContent }) {
.finally(() => setLoading(false))
}, [])
const [query, setQuery] = useState('')
const [shown, setShown] = useState(12)
const [activeTag, setActiveTag] = useState('')
const [latest, ...rest] = episodes
const activeHosts = (nuggets.hosts ?? []).filter(h => !h.archived)
const q = query.trim().toLowerCase()
const allTags = Array.from(new Set(episodes.flatMap(e => e.tags ?? []))).sort((a, b) => a.localeCompare(b))
const filtering = q.length > 0 || activeTag !== ''
const filtered = (filtering ? episodes : rest).filter(e =>
(!q || `${e.title} ${e.description} ${(e.tags ?? []).join(' ')}`.toLowerCase().includes(q)) &&
(!activeTag || (e.tags ?? []).includes(activeTag)),
)
const visible = filtered.slice(0, shown)
const fmtDate = (d: string) => {
const t = new Date(d)
return Number.isNaN(t.getTime()) ? '' : t.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const listenLinks = (nuggets.spotifyUrl || nuggets.appleUrl) && (
<div className="nuggets-platform-links">
{nuggets.spotifyUrl && <a href={nuggets.spotifyUrl} target="_blank" rel="noopener noreferrer" className="btn-primary">Listen on Spotify</a>}
{nuggets.appleUrl && <a href={nuggets.appleUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary">Listen on Apple Podcasts</a>}
</div>
)
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Sub-Show" title={nuggets.title} desc={nuggets.tagline} />
{(nuggets.podcastArtUrl || nuggets.description || nuggets.spotifyUrl || nuggets.appleUrl) && (
<section className="section-nuggets-intro" aria-label={`About ${nuggets.title}`}>
<div className="section-inner nuggets-intro-layout">
{nuggets.podcastArtUrl && <img src={nuggets.podcastArtUrl} alt={nuggets.title} className="nuggets-podcast-art" />}
<div className="nuggets-intro-body">
{nuggets.description && <p className="nuggets-description">{nuggets.description}</p>}
{(nuggets.spotifyUrl || nuggets.appleUrl) && (
<div className="nuggets-platform-links">
{nuggets.spotifyUrl && <a href={nuggets.spotifyUrl} target="_blank" rel="noopener noreferrer" className="btn-primary">Listen on Spotify</a>}
{nuggets.appleUrl && <a href={nuggets.appleUrl} target="_blank" rel="noopener noreferrer" className="btn-secondary">Listen on Apple Podcasts</a>}
</div>
)}
</div>
<section className="nug-hero" aria-label={`About ${nuggets.title}`}>
<div className="section-inner nug-hero-inner">
{nuggets.podcastArtUrl && <img src={nuggets.podcastArtUrl} alt={nuggets.title} className="nug-hero-art" />}
<div className="nug-hero-body">
<p className="eyebrow">Sub-Show</p>
<h1 className="nug-hero-title">{nuggets.title}</h1>
{nuggets.tagline && <p className="nug-hero-tagline">{nuggets.tagline}</p>}
{nuggets.description && <p className="nuggets-description">{nuggets.description}</p>}
{listenLinks}
</div>
</section>
)}
</div>
</section>
<section className="section-nuggets-body" aria-label={`${nuggets.title} hosts and episodes`}>
<div className="section-inner">
{loading ? (
<p className="nuggets-episodes-note">Loading episodes…</p>
) : !configured ? (
<p className="nuggets-episodes-note">Episodes are coming soon — check back once the show launches.</p>
) : episodes.length === 0 ? (
<p className="nuggets-episodes-note">No episodes published yet.</p>
) : (
<>
{latest && (
<div className="nuggets-latest-episode nug-latest">
<p className="nuggets-latest-episode-eyebrow">Latest Episode{fmtDate(latest.pubDate) ? ` · ${fmtDate(latest.pubDate)}` : ''}{latest.duration ? ` · ${latest.duration}` : ''}</p>
<h2 className="nuggets-latest-episode-title">{latest.title}</h2>
{latest.description && <p className="nuggets-latest-episode-desc">{latest.description}</p>}
{latest.audioUrl && <EpisodeAudioPlayer src={latest.audioUrl} title={latest.title} size="full" />}
</div>
)}
<div className="nug-list-head">
<h2 className="section-heading">{filtering ? 'Search results' : 'All Episodes'}</h2>
{episodes.length > 4 && (
<input
type="search"
className="nl-search"
placeholder="Search episodes…"
aria-label="Search episodes"
value={query}
onChange={e => { setQuery(e.target.value); setShown(12) }}
/>
)}
</div>
{allTags.length > 0 && (
<div className="nug-tags" role="group" aria-label="Filter by tag">
<button type="button" className={`nug-tag${activeTag === '' ? ' nug-tag--active' : ''}`} onClick={() => { setActiveTag(''); setShown(12) }}>All</button>
{allTags.map(t => (
<button key={t} type="button" className={`nug-tag${activeTag === t ? ' nug-tag--active' : ''}`} aria-pressed={activeTag === t} onClick={() => { setActiveTag(activeTag === t ? '' : t); setShown(12) }}>{t}</button>
))}
</div>
)}
{filtered.length === 0 ? (
<p className="nuggets-episodes-note">{filtering ? 'No episodes match your search.' : 'More episodes coming soon.'}</p>
) : (
<div className="nug-grid">
{visible.map((ep, i) => (
<article key={ep.audioUrl || i} className="nug-card">
<p className="nug-card-meta">
{[ep.episode ? `Ep. ${ep.episode}` : '', fmtDate(ep.pubDate), ep.duration].filter(Boolean).join(' · ')}
</p>
<h3 className="nug-card-title">{ep.title}</h3>
{ep.description && <p className="nug-card-desc">{ep.description}</p>}
{(ep.tags ?? []).length > 0 && (
<div className="nug-card-tags">
{(ep.tags ?? []).map(t => <button key={t} type="button" className="nug-tag nug-tag--small" onClick={() => { setActiveTag(t); setShown(12) }}>{t}</button>)}
</div>
)}
{ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
</article>
))}
</div>
)}
{filtered.length > shown && (
<div className="nug-more">
<button type="button" className="btn-secondary" onClick={() => setShown(n => n + 12)}>Show more episodes</button>
</div>
)}
</>
)}
{activeHosts.length > 0 && (
<div className="nuggets-hosts-row-wrap">
<p className="eyebrow" style={{ marginBottom: '1rem' }}>Meet the Hosts</p>
<div className="nuggets-hosts-row">
<div className="nug-hosts">
<h2 className="section-heading">Meet the Hosts</h2>
<div className="nug-hosts-grid">
{activeHosts.map(host => (
<Link key={host.id} to={`/nuggets/hosts/${host.id}`} className="nuggets-host-pill">
{host.imageUrl && <img src={host.imageUrl} alt={host.name} className="nuggets-host-pill-img" loading="lazy" />}
<span className="nuggets-host-pill-name">{host.name}</span>
<Link key={host.id} to={`/nuggets/hosts/${host.id}`} className="nug-host-card">
{host.imageUrl && <img src={host.imageUrl} alt={host.name} className="nug-host-img" loading="lazy" />}
<div>
<span className="nug-host-name">{host.name}</span>
{host.role && <span className="nug-host-role">{host.role}</span>}
</div>
</Link>
))}
</div>
</div>
)}
<div className="nuggets-episodes-full">
<p className="eyebrow">Episodes</p>
{loading ? (
<p className="nuggets-episodes-note">Loading episodes…</p>
) : !configured ? (
<p className="nuggets-episodes-note">Episodes are coming soon — check back once the show launches.</p>
) : episodes.length === 0 ? (
<p className="nuggets-episodes-note">No episodes published yet.</p>
) : (
<>
<div className="nuggets-latest-episode">
<p className="nuggets-latest-episode-eyebrow">Latest Episode</p>
<h2 className="nuggets-latest-episode-title">{latest.title}</h2>
{latest.description && <p className="nuggets-latest-episode-desc">{latest.description}</p>}
{latest.audioUrl && <EpisodeAudioPlayer src={latest.audioUrl} title={latest.title} size="full" />}
</div>
{rest.length > 0 && (
<ol className="finished-series-playlist nuggets-episode-list">
{rest.map((ep, i) => (
<li key={ep.audioUrl || i} className="finished-series-episode">
<span className="finished-series-ep-num">{ep.episode || i + 2}</span>
<div className="finished-series-ep-body">
<p className="finished-series-ep-title">{ep.title}</p>
{ep.description && <p className="nuggets-latest-episode-desc" style={{ fontSize: '0.88rem', margin: '3px 0 6px' }}>{ep.description}</p>}
{ep.duration && <p className="finished-series-ep-meta">{ep.duration}</p>}
{ep.audioUrl && <EpisodeAudioPlayer src={ep.audioUrl} title={ep.title} />}
</div>
</li>
))}
</ol>
)}
</>
)}
</div>
</div>
</section>
@@ -3516,6 +3569,7 @@ function NewsletterArchivePage({ content }: { content: SiteContent }) {
)
const [issues, setIssues] = useState<NewsletterIssueSummary[]>([])
const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
useEffect(() => {
fetch('/api/newsletter/issues')
@@ -3525,39 +3579,96 @@ function NewsletterArchivePage({ content }: { content: SiteContent }) {
.finally(() => setLoading(false))
}, [])
const issueDate = (i: NewsletterIssueSummary) => new Date(i.sentAt ?? i.publishedAt)
const fmtDate = (i: NewsletterIssueSummary) =>
issueDate(i).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
const q = query.trim().toLowerCase()
const searching = q.length > 0
const matches = searching
? issues.filter(i => `${i.title} ${i.subject} ${i.description}`.toLowerCase().includes(q))
: issues
const featured = !searching ? issues[0] : undefined
const rest = featured ? matches.slice(1) : matches
const byYear: { year: number; items: NewsletterIssueSummary[] }[] = []
for (const i of rest) {
const year = issueDate(i).getFullYear()
const group = byYear.find(g => g.year === year)
if (group) group.items.push(i)
else byYear.push({ year, items: [i] })
}
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Newsletter" title="Newsletter Archive" desc="Past issues of the Verse by Verse with Nate newsletter — episode notes, study excerpts, and Greek word studies." />
<section className="section-resources" aria-label="Newsletter archive">
<div className="section-inner">
{loading ? (
<p className="episode-seo-intro">Loading issues…</p>
) : issues.length === 0 ? (
<p className="episode-seo-intro">No newsletter issues published yet.</p>
<PageBanner eyebrow="Newsletter" title="The Newsletter" desc="Episode notes, study excerpts, and Greek word studies from Verse by Verse with Nate — in your inbox and in the archive." />
<section className="nl-top" aria-label="Latest issue and sign-up">
<div className="section-inner nl-top-grid">
{featured ? (
<a href={`/newsletter/${featured.slug}`} className="nl-featured">
<span className="nl-featured-tag">Latest issue</span>
<h2 className="nl-featured-title">{featured.title}</h2>
{featured.description && <p className="nl-featured-desc">{featured.description}</p>}
<div className="nl-featured-foot">
<span className="nl-date">{fmtDate(featured)}</span>
<span className="nl-read">Read issue →</span>
</div>
</a>
) : (
<div className="resources-list">
{issues.map(issue => (
<a key={issue.id} href={`/newsletter/${issue.slug}`} className="resource-download-card">
<div className="resource-download-header">
<div className="resource-download-meta">
<span className="resource-link-label">{issue.title}</span>
{issue.description && <p className="resource-link-description">{issue.description}</p>}
{issue.sentAt && (
<span className="resource-link-tags">
{new Date(issue.sentAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</span>
)}
<span className="resource-link-action">Read issue →</span>
</div>
</div>
</a>
))}
<div className="nl-featured nl-featured--empty">
<span className="nl-featured-tag">Archive</span>
<h2 className="nl-featured-title">{loading ? 'Loading issues…' : 'First issue coming soon'}</h2>
{!loading && <p className="nl-featured-desc">Subscribe and you’ll get it the moment it’s sent.</p>}
</div>
)}
<HomepageNewsletterSection embedded />
</div>
</section>
<section className="nl-archive" aria-label="Newsletter archive">
<div className="section-inner">
<div className="nl-archive-head">
<h2 className="section-heading">Past Issues</h2>
{issues.length > 1 && (
<input
type="search"
className="nl-search"
placeholder="Search issues…"
aria-label="Search newsletter issues"
value={query}
onChange={e => setQuery(e.target.value)}
/>
)}
</div>
{!loading && searching && matches.length === 0 && (
<p className="episode-seo-intro">No issues match “{query.trim()}”.</p>
)}
{!loading && !searching && issues.length <= 1 && (
<p className="episode-seo-intro">{issues.length === 0 ? 'No newsletter issues published yet.' : 'More issues will appear here as they’re sent.'}</p>
)}
{byYear.map(group => (
<div key={group.year} className="nl-year">
<h3 className="nl-year-label">{group.year}</h3>
<div className="nl-grid">
{group.items.map(issue => (
<a key={issue.id} href={`/newsletter/${issue.slug}`} className="nl-card">
<span className="nl-date">{fmtDate(issue)}</span>
<span className="nl-card-title">{issue.title}</span>
{issue.description && <p className="nl-card-desc">{issue.description}</p>}
<span className="nl-read">Read issue →</span>
</a>
))}
</div>
</div>
))}
</div>
</section>
<CustomBlocksSection content={content} page="newsletter" />
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />