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

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.36.5",
"version": "2.37.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -28,6 +28,7 @@ import {
loadStudySessionsFromDisk,
queueStudySessionsWrite,
loadNewsletterIssuesFromDisk,
loadNuggetsTagsFromDisk,
createBackupSnapshot,
refreshContentCaches,
queueHitStatsWrite,
@@ -194,6 +195,7 @@ Promise.all([
loadWebhooksFromDisk(),
loadStudySessionsFromDisk(),
loadNewsletterIssuesFromDisk(),
loadNuggetsTagsFromDisk(),
refreshContentCaches(),
])
.catch(err => {
+1
View File
@@ -73,6 +73,7 @@ export const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit-log.json')
export const WEBHOOKS_FILE = path.join(DATA_DIR, 'webhooks.json')
export const STUDY_SESSIONS_FILE = path.join(DATA_DIR, 'study-sessions.json')
export const NEWSLETTER_ISSUES_FILE = path.join(DATA_DIR, 'newsletter-issues.json')
export const NUGGETS_TAGS_FILE = path.join(DATA_DIR, 'nuggets-tags.json')
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
// Seafile integration for Nuggets episode uploads
+25
View File
@@ -37,6 +37,7 @@ import {
WEBHOOKS_FILE,
STUDY_SESSIONS_FILE,
NEWSLETTER_ISSUES_FILE,
NUGGETS_TAGS_FILE,
EMPTY_HIT_STATS,
EMPTY_VISITOR_STATS,
DEFAULT_REPLY_TEMPLATES,
@@ -1588,3 +1589,27 @@ export function loadNewsletterIssuesFromDisk() {
state.newsletterIssues = []
})
}
// ── Nuggets Episode Tags ─────────────────────────────────────────────────────
export function queueNuggetsTagsWrite() {
state.nuggetsEpisodeTagsWritePromise = state.nuggetsEpisodeTagsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(NUGGETS_TAGS_FILE, JSON.stringify({ tags: state.nuggetsEpisodeTags }, null, 2), 'utf8')
})
.catch(err => {
console.error('[nuggets-tags] failed to write:', err)
})
}
export function loadNuggetsTagsFromDisk() {
return readFile(NUGGETS_TAGS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.nuggetsEpisodeTags = parsed?.tags && typeof parsed.tags === 'object' ? parsed.tags : {}
})
.catch(() => {
state.nuggetsEpisodeTags = {}
})
}
+39 -2
View File
@@ -1,5 +1,7 @@
import { parseRssItems } from '../rss-helpers.js'
import { state } from '../state.js'
import { requireAdminAuth } from '../auth.js'
import { queueNuggetsTagsWrite } from '../data.js'
let cache = null // { url, episodes }
let cacheAt = 0
@@ -37,12 +39,47 @@ export async function fetchNuggetsEpisodes() {
}
}
function withTags(episodes) {
return episodes.map(ep => ({ ...ep, tags: state.nuggetsEpisodeTags[ep.audioUrl] ?? [] }))
}
function cleanTags(input) {
const list = Array.isArray(input) ? input : []
const seen = new Set()
const out = []
for (const t of list) {
const tag = String(t ?? '').trim().replace(/\s+/g, ' ').slice(0, 40)
const key = tag.toLowerCase()
if (tag && !seen.has(key)) { seen.add(key); out.push(tag) }
}
return out.slice(0, 12)
}
export function register(app) {
app.get('/api/admin/nuggets/episode-tags', requireAdminAuth, async (_req, res) => {
try {
const episodes = await fetchNuggetsEpisodes()
res.json({ episodes: withTags(episodes).map(({ title, pubDate, audioUrl, episode, tags }) => ({ title, pubDate, audioUrl, episode, tags })) })
} catch (err) {
res.status(502).json({ message: 'Could not load episodes from the feed.' })
}
})
app.put('/api/admin/nuggets/episode-tags', requireAdminAuth, (req, res) => {
const audioUrl = String(req.body?.audioUrl ?? '')
if (!audioUrl) { res.status(400).json({ message: 'audioUrl is required.' }); return }
const tags = cleanTags(req.body?.tags)
if (tags.length) state.nuggetsEpisodeTags[audioUrl] = tags
else delete state.nuggetsEpisodeTags[audioUrl]
queueNuggetsTagsWrite()
res.json({ ok: true, tags })
})
app.get('/api/nuggets/episodes', async (_req, res) => {
const configured = Boolean(feedUrl())
try {
const episodes = await fetchNuggetsEpisodes()
res.json({ episodes: episodes.slice(0, 6), configured })
res.json({ episodes: withTags(episodes.slice(0, 6)), configured })
} catch (err) {
console.error('[nuggets/episodes] RSS fetch error:', err.message)
res.json({ episodes: [], configured })
@@ -53,7 +90,7 @@ export function register(app) {
const configured = Boolean(feedUrl())
try {
const episodes = await fetchNuggetsEpisodes()
res.json({ episodes, configured })
res.json({ episodes: withTags(episodes), configured })
} catch (err) {
console.error('[nuggets/episodes/all] RSS fetch error:', err.message)
res.json({ episodes: [], configured })
+3
View File
@@ -96,6 +96,9 @@ export const state = {
// newsletterIssues: Array<{ id, resendBroadcastId, slug, title, subject, description, html, sentAt, publishedAt, public }>
newsletterIssues: [],
newsletterIssuesWritePromise: Promise.resolve(),
// nuggetsEpisodeTags: { [audioUrl]: string[] } — admin-assigned tags for Nuggets RSS episodes
nuggetsEpisodeTags: {},
nuggetsEpisodeTagsWritePromise: Promise.resolve(),
auditLog: [],
+112 -1
View File
@@ -740,7 +740,7 @@ type AdminView =
| 'about:show-copy' | 'about:bio' | 'about:testimonials'
| 'contact:profile' | 'contact:copy'
| 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:workflow' | 'podcast:season-check' | 'podcast:rss'
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow'
| 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:guidelines' | 'nuggets:history' | 'nuggets:workflow' | 'nuggets:tags'
| 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources'
| 'inbox'
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
@@ -802,6 +802,7 @@ const ADMIN_NAV: NavGroup[] = [
{ kind: 'leaf', view: 'nuggets:announcement', label: 'Announcement' },
{ kind: 'leaf', view: 'nuggets:guidelines', label: 'Recording Guidelines' },
{ kind: 'leaf', view: 'nuggets:workflow', label: 'Episode Workflow' },
{ kind: 'leaf', view: 'nuggets:tags', label: 'Episode Tags' },
{ kind: 'leaf', view: 'nuggets:history', label: 'Upload History' },
]},
{ kind: 'leaf', view: 'colossians-study', label: 'Studies' },
@@ -1627,6 +1628,115 @@ function NuggetsHistoryAdmin() {
)
}
// ── Nuggets Episode Tags admin ─────────────────────────────────────────────
interface NuggetsTagEpisode {
title: string
pubDate: string
audioUrl: string
episode: string
tags: string[]
}
function NuggetsTagsAdmin() {
const [episodes, setEpisodes] = useState<NuggetsTagEpisode[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [drafts, setDrafts] = useState<Record<string, string>>({})
const [filter, setFilter] = useState('')
useEffect(() => {
fetch('/api/admin/nuggets/episode-tags')
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(d => setEpisodes(Array.isArray(d.episodes) ? d.episodes : []))
.catch(() => setError('Could not load episodes. Check the RSS feed URL in Show Settings.'))
.finally(() => setLoading(false))
}, [])
const allTags = Array.from(new Set(episodes.flatMap(e => e.tags))).sort((a, b) => a.localeCompare(b))
async function saveTags(audioUrl: string, tags: string[]) {
const prev = episodes
setEpisodes(eps => eps.map(e => e.audioUrl === audioUrl ? { ...e, tags } : e))
try {
const r = await fetch('/api/admin/nuggets/episode-tags', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audioUrl, tags }),
})
if (!r.ok) throw new Error()
const d = await r.json()
setEpisodes(eps => eps.map(e => e.audioUrl === audioUrl ? { ...e, tags: d.tags ?? tags } : e))
} catch {
setEpisodes(prev)
setError('Could not save tags. Please try again.')
}
}
function addTags(ep: NuggetsTagEpisode) {
const raw = (drafts[ep.audioUrl] ?? '').split(',').map(t => t.trim()).filter(Boolean)
if (!raw.length) return
setDrafts(d => ({ ...d, [ep.audioUrl]: '' }))
saveTags(ep.audioUrl, [...ep.tags, ...raw.filter(t => !ep.tags.some(x => x.toLowerCase() === t.toLowerCase()))])
}
const visible = filter.trim()
? episodes.filter(e => `${e.title} ${e.tags.join(' ')}`.toLowerCase().includes(filter.toLowerCase()))
: episodes
return (
<section className="admin-panel-section" aria-label="Nuggets episode tags">
<div className="admin-panel-head">
<h2>Nuggets — Episode Tags</h2>
<p>Tag episodes by topic or book. Visitors can filter the Nuggets page by these tags. Type a tag and press Enter (or separate several with commas).</p>
</div>
{error && <p className="admin-stats-note" role="alert">{error}</p>}
{loading ? <p className="admin-stats-note">Loading…</p> : (
<>
<datalist id="nuggets-tag-suggestions">
{allTags.map(t => <option key={t} value={t} />)}
</datalist>
{episodes.length > 0 && (
<div className="admin-field" style={{ maxWidth: 320, marginBottom: 12 }}>
<input type="text" placeholder="Filter by title or tag…" value={filter} onChange={e => setFilter(e.target.value)} />
</div>
)}
{visible.length === 0 ? (
<p className="admin-stats-note">{episodes.length === 0 ? 'No episodes found in the feed yet.' : 'No results match that filter.'}</p>
) : visible.map(ep => (
<div key={ep.audioUrl} style={{ padding: '10px 0', borderBottom: '1px solid var(--color-border, #e5e0d5)' }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>
{ep.episode ? `Ep. ${ep.episode} — ` : ''}{ep.title}
<span style={{ fontWeight: 400, opacity: 0.6, marginLeft: 8, fontSize: '0.8rem' }}>
{Number.isNaN(new Date(ep.pubDate).getTime()) ? '' : new Date(ep.pubDate).toLocaleDateString()}
</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
{ep.tags.map(t => (
<span key={t} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, background: 'rgba(201,168,76,0.15)', borderRadius: 999, padding: '2px 10px', fontSize: '0.82rem' }}>
{t}
<button type="button" aria-label={`Remove tag ${t}`} onClick={() => saveTags(ep.audioUrl, ep.tags.filter(x => x !== t))} style={{ border: 'none', background: 'none', cursor: 'pointer', padding: 0, fontSize: '1rem', lineHeight: 1 }}>×</button>
</span>
))}
<input
type="text"
list="nuggets-tag-suggestions"
placeholder="Add tag…"
aria-label={`Add tag to ${ep.title}`}
value={drafts[ep.audioUrl] ?? ''}
onChange={e => setDrafts(d => ({ ...d, [ep.audioUrl]: e.target.value }))}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addTags(ep) } }}
onBlur={() => addTags(ep)}
style={{ width: 160 }}
/>
</div>
</div>
))}
</>
)}
</section>
)
}
// ── Nuggets Episode Workflow admin ─────────────────────────────────────────
type WorkflowStepKey = 'listen' | 'coldOpen' | 'recordColdOpen' | 'edit' | 'spotify'
@@ -6626,6 +6736,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
{/* NUGGETS UPLOAD HISTORY */}
{adminView === 'nuggets:workflow' && <NuggetsWorkflowAdmin />}
{adminView === 'nuggets:history' && <NuggetsHistoryAdmin />}
{adminView === 'nuggets:tags' && <NuggetsTagsAdmin />}
{/* STUDY COMMENTS */}
{adminView === 'study-comments' && (() => {
+71
View File
@@ -13908,3 +13908,74 @@ a.study-track-card:hover {
.home-feature--image { background-size: cover; background-position: center; }
.home-feature--image h3 { text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6); }
.home-feature--image p { color: #d9ceb0; }
/* ── Newsletter page ── */
.nl-top { padding: 3rem 0 1rem; }
.nl-top-grid { display: grid; grid-template-columns: 1.3fr 1fr; gap: 1.5rem; align-items: stretch; }
.nl-top .home-connect-card--newsletter { margin: 0; }
.nl-featured {
display: flex; flex-direction: column; gap: 0.9rem; padding: 2rem;
background: linear-gradient(145deg, #17170f, #0c0c08);
border: 1px solid rgba(201, 168, 76, 0.3); border-radius: 16px;
text-decoration: none; color: inherit;
transition: transform 160ms ease, border-color 160ms ease;
}
a.nl-featured:hover { transform: translateY(-2px); border-color: rgba(201, 168, 76, 0.6); }
.nl-featured-tag { align-self: flex-start; font-size: 0.72rem; letter-spacing: 0.14em; text-transform: uppercase; color: #c9a84c; border: 1px solid rgba(201, 168, 76, 0.4); border-radius: 999px; padding: 0.25rem 0.7rem; }
.nl-featured-title { font-size: clamp(1.5rem, 3vw, 2.1rem); line-height: 1.2; color: #f0e9cc; margin: 0; }
.nl-featured-desc { color: #a89a6a; margin: 0; line-height: 1.6; }
.nl-featured-foot { margin-top: auto; display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.nl-date { font-size: 0.85rem; color: #8a7e55; }
.nl-read { color: #c9a84c; font-weight: 600; font-size: 0.9rem; }
.nl-archive { padding: 2rem 0 4rem; }
.nl-archive-head { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.nl-archive-head .section-heading { margin: 0; }
.nl-search { flex: 0 1 260px; padding: 0.6rem 1rem; border-radius: 999px; border: 1px solid rgba(201, 168, 76, 0.3); background: #1a1a12; color: #f0e9cc; font-size: 0.95rem; outline: none; }
.nl-search:focus { border-color: rgba(201, 168, 76, 0.7); }
.nl-year { margin-top: 2rem; }
.nl-year-label { font-size: 0.85rem; letter-spacing: 0.18em; color: #c9a84c; margin: 0 0 0.9rem; padding-bottom: 0.5rem; border-bottom: 1px solid rgba(201, 168, 76, 0.18); }
.nl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
.nl-card {
display: flex; flex-direction: column; gap: 0.5rem; padding: 1.2rem 1.3rem;
background: #111; border: 1px solid rgba(201, 168, 76, 0.18); border-radius: 14px;
text-decoration: none; color: inherit;
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
}
.nl-card:hover { transform: translateY(-2px); background: rgba(201, 168, 76, 0.06); border-color: rgba(201, 168, 76, 0.32); }
.nl-card-title { font-size: 1.1rem; font-weight: 600; color: #f0e9cc; line-height: 1.3; }
.nl-card-desc { margin: 0; color: #a89a6a; font-size: 0.9rem; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.nl-card .nl-read { margin-top: auto; padding-top: 0.4rem; }
@media (max-width: 800px) { .nl-top-grid { grid-template-columns: 1fr; } .nl-featured { padding: 1.5rem; } }
/* ── Nuggets page ── */
.nug-hero { padding: 3.5rem 0 1rem; border-bottom: 1px solid rgba(201, 168, 76, 0.15); background: radial-gradient(ellipse at top left, rgba(201, 168, 76, 0.08), transparent 60%); }
.nug-hero-inner { display: flex; gap: 2.5rem; align-items: center; flex-wrap: wrap; padding-bottom: 2.5rem; }
.nug-hero-art { width: 220px; height: 220px; object-fit: cover; border-radius: 18px; border: 1px solid rgba(201, 168, 76, 0.3); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.45); flex-shrink: 0; }
.nug-hero-body { flex: 1; min-width: 260px; }
.nug-hero-title { font-family: var(--brand-font-heading); font-size: clamp(2rem, 5vw, 3.2rem); color: #f0e9cc; margin: 0.2rem 0 0.4rem; line-height: 1.1; }
.nug-hero-tagline { font-size: 1.15rem; color: #c9a84c; margin: 0 0 1rem; }
.nug-latest { margin: 0 0 3rem; padding: 2rem; border-color: rgba(201, 168, 76, 0.4); background: linear-gradient(145deg, #17170f, #0c0c08); }
.nug-list-head { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
.nug-list-head .section-heading { margin: 0; }
.nug-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
.nug-card { display: flex; flex-direction: column; gap: 0.5rem; padding: 1.25rem 1.3rem; background: #111; border: 1px solid rgba(201, 168, 76, 0.18); border-radius: 14px; transition: border-color 160ms ease; }
.nug-card:hover { border-color: rgba(201, 168, 76, 0.4); }
.nug-card-meta { margin: 0; font-size: 0.78rem; letter-spacing: 0.06em; text-transform: uppercase; color: #8a7e55; }
.nug-card-title { margin: 0; font-size: 1.1rem; line-height: 1.3; color: #f0e9cc; }
.nug-card-desc { margin: 0; color: #a89a6a; font-size: 0.9rem; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.nug-card > :last-child { margin-top: auto; }
.nug-more { text-align: center; margin-top: 1.75rem; }
.nug-hosts { margin-top: 4rem; padding-top: 2.5rem; border-top: 1px solid rgba(201, 168, 76, 0.15); }
.nug-hosts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1rem; margin-top: 1.25rem; }
.nug-host-card { display: flex; align-items: center; gap: 1rem; padding: 1rem 1.2rem; background: #111; border: 1px solid rgba(201, 168, 76, 0.18); border-radius: 14px; text-decoration: none; transition: transform 160ms ease, border-color 160ms ease; }
.nug-host-card:hover { transform: translateY(-2px); border-color: rgba(201, 168, 76, 0.45); }
.nug-host-img { width: 64px; height: 64px; object-fit: cover; border-radius: 50%; border: 2px solid rgba(201, 168, 76, 0.35); flex-shrink: 0; }
.nug-host-name { display: block; font-family: var(--brand-font-heading); font-size: 1.05rem; color: #e8d9b5; }
.nug-host-role { display: block; font-size: 0.85rem; color: #a89a6a; margin-top: 0.15rem; }
@media (max-width: 640px) { .nug-hero-art { width: 150px; height: 150px; } .nug-latest { padding: 1.25rem; } .nug-grid { grid-template-columns: 1fr; } }
.nug-tags { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1.25rem; }
.nug-tag { font: inherit; font-size: 0.85rem; color: #c9a84c; background: transparent; border: 1px solid rgba(201, 168, 76, 0.35); border-radius: 999px; padding: 0.3rem 0.9rem; cursor: pointer; transition: background 140ms ease, color 140ms ease; }
.nug-tag:hover { background: rgba(201, 168, 76, 0.12); }
.nug-tag--active, .nug-tag--active:hover { background: #c9a84c; color: #0c0c08; border-color: #c9a84c; }
.nug-tag--small { font-size: 0.74rem; padding: 0.15rem 0.6rem; }
.nug-card-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; }
+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} />