diff --git a/package.json b/package.json index 3f7bfb6..73c0702 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.36.5", + "version": "2.37.0", "type": "module", "scripts": { "dev": "vite", diff --git a/server.js b/server.js index 0314c14..aa17349 100644 --- a/server.js +++ b/server.js @@ -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 => { diff --git a/server/config.js b/server/config.js index d5099d6..1b1f355 100644 --- a/server/config.js +++ b/server/config.js @@ -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 diff --git a/server/data.js b/server/data.js index 0462789..eda49cd 100644 --- a/server/data.js +++ b/server/data.js @@ -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 = {} + }) +} diff --git a/server/routes/nuggets.js b/server/routes/nuggets.js index 67a4a0f..ae17abb 100644 --- a/server/routes/nuggets.js +++ b/server/routes/nuggets.js @@ -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 }) diff --git a/server/state.js b/server/state.js index 560a181..fd0b543 100644 --- a/server/state.js +++ b/server/state.js @@ -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: [], diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index f9a7bc7..c0fd948 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [drafts, setDrafts] = useState>({}) + 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 ( +
+
+

Nuggets — Episode Tags

+

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).

+
+ {error &&

{error}

} + {loading ?

Loading…

: ( + <> + + {allTags.map(t => + {episodes.length > 0 && ( +
+ setFilter(e.target.value)} /> +
+ )} + {visible.length === 0 ? ( +

{episodes.length === 0 ? 'No episodes found in the feed yet.' : 'No results match that filter.'}

+ ) : visible.map(ep => ( +
+
+ {ep.episode ? `Ep. ${ep.episode} — ` : ''}{ep.title} + + {Number.isNaN(new Date(ep.pubDate).getTime()) ? '' : new Date(ep.pubDate).toLocaleDateString()} + +
+
+ {ep.tags.map(t => ( + + {t} + + + ))} + setDrafts(d => ({ ...d, [ep.audioUrl]: e.target.value }))} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addTags(ep) } }} + onBlur={() => addTags(ep)} + style={{ width: 160 }} + /> +
+
+ ))} + + )} +
+ ) +} + // ── 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' && } {adminView === 'nuggets:history' && } + {adminView === 'nuggets:tags' && } {/* STUDY COMMENTS */} {adminView === 'study-comments' && (() => { diff --git a/src/App.css b/src/App.css index 133110e..f064940 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } diff --git a/src/App.tsx b/src/App.tsx index 7af0695..8d7d75f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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) && ( +
+ {nuggets.spotifyUrl && Listen on Spotify} + {nuggets.appleUrl && Listen on Apple Podcasts} +
+ ) return (
- - {(nuggets.podcastArtUrl || nuggets.description || nuggets.spotifyUrl || nuggets.appleUrl) && ( -
-
- {nuggets.podcastArtUrl && {nuggets.title}} -
- {nuggets.description &&

{nuggets.description}

} - {(nuggets.spotifyUrl || nuggets.appleUrl) && ( -
- {nuggets.spotifyUrl && Listen on Spotify} - {nuggets.appleUrl && Listen on Apple Podcasts} -
- )} -
+
+
+ {nuggets.podcastArtUrl && {nuggets.title}} +
+

Sub-Show

+

{nuggets.title}

+ {nuggets.tagline &&

{nuggets.tagline}

} + {nuggets.description &&

{nuggets.description}

} + {listenLinks}
-
- )} +
+
+ {loading ? ( +

Loading episodes…

+ ) : !configured ? ( +

Episodes are coming soon — check back once the show launches.

+ ) : episodes.length === 0 ? ( +

No episodes published yet.

+ ) : ( + <> + {latest && ( +
+

Latest Episode{fmtDate(latest.pubDate) ? ` · ${fmtDate(latest.pubDate)}` : ''}{latest.duration ? ` · ${latest.duration}` : ''}

+

{latest.title}

+ {latest.description &&

{latest.description}

} + {latest.audioUrl && } +
+ )} + +
+

{filtering ? 'Search results' : 'All Episodes'}

+ {episodes.length > 4 && ( + { setQuery(e.target.value); setShown(12) }} + /> + )} +
+ {allTags.length > 0 && ( +
+ + {allTags.map(t => ( + + ))} +
+ )} + {filtered.length === 0 ? ( +

{filtering ? 'No episodes match your search.' : 'More episodes coming soon.'}

+ ) : ( +
+ {visible.map((ep, i) => ( +
+

+ {[ep.episode ? `Ep. ${ep.episode}` : '', fmtDate(ep.pubDate), ep.duration].filter(Boolean).join(' · ')} +

+

{ep.title}

+ {ep.description &&

{ep.description}

} + {(ep.tags ?? []).length > 0 && ( +
+ {(ep.tags ?? []).map(t => )} +
+ )} + {ep.audioUrl && } +
+ ))} +
+ )} + {filtered.length > shown && ( +
+ +
+ )} + + )} + {activeHosts.length > 0 && ( -
-

Meet the Hosts

-
+
+

Meet the Hosts

+
{activeHosts.map(host => ( - - {host.imageUrl && {host.name}} - {host.name} + + {host.imageUrl && {host.name}} +
+ {host.name} + {host.role && {host.role}} +
))}
)} - -
-

Episodes

- {loading ? ( -

Loading episodes…

- ) : !configured ? ( -

Episodes are coming soon — check back once the show launches.

- ) : episodes.length === 0 ? ( -

No episodes published yet.

- ) : ( - <> -
-

Latest Episode

-

{latest.title}

- {latest.description &&

{latest.description}

} - {latest.audioUrl && } -
- {rest.length > 0 && ( -
    - {rest.map((ep, i) => ( -
  1. - {ep.episode || i + 2} -
    -

    {ep.title}

    - {ep.description &&

    {ep.description}

    } - {ep.duration &&

    {ep.duration}

    } - {ep.audioUrl && } -
    -
  2. - ))} -
- )} - - )} -
@@ -3516,6 +3569,7 @@ function NewsletterArchivePage({ content }: { content: SiteContent }) { ) const [issues, setIssues] = useState([]) 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 (
- -
-
- {loading ? ( -

Loading issues…

- ) : issues.length === 0 ? ( -

No newsletter issues published yet.

+ + +
+
+ +
+
+
+

Past Issues

+ {issues.length > 1 && ( + setQuery(e.target.value)} + /> + )} +
+ + {!loading && searching && matches.length === 0 && ( +

No issues match “{query.trim()}”.

+ )} + {!loading && !searching && issues.length <= 1 && ( +

{issues.length === 0 ? 'No newsletter issues published yet.' : 'More issues will appear here as they’re sent.'}

+ )} + + {byYear.map(group => ( + + ))} +
+
+