Add a newsletter archive so past issues can be shared and found; v2.3.0
Publish Container / build (push) Successful in 20m51s

Web-hosted copies of episode newsletters, published from Resend broadcasts
tagged with the "New Episode" topic. Gives Facebook (and anyone else) a
proper link to share instead of raw HTML email, and makes issues findable
via site search and Google.

- Admin "Newsletter Archive" tab lists sent Resend broadcasts, flags ones
  tagged "New Episode", and publishes them with one click (pulls the HTML
  straight from Resend's API)
- Public /newsletter archive index and /newsletter/:slug issue pages,
  server-rendered with per-issue Open Graph tags for social previews
- Unsubscribe merge-tag stripped from the web copy; archive + subscribe
  links appended
- Issues added to sitemap.xml and the on-site search index

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-28 12:17:13 -04:00
1 parent 60b23b0fba
commit fe4c09f8a3
11 files changed
+516 -4

No files matched your search

+21 -2
View File
@@ -2,7 +2,7 @@ import { useMemo, useState, useEffect, useRef } from 'react'
import Fuse from 'fuse.js'
import type { SiteContent } from '../content'
export type SearchResultType = 'episode' | 'question' | 'resource' | 'series'
export type SearchResultType = 'episode' | 'question' | 'resource' | 'series' | 'newsletter'
export interface SearchResult {
id: string
@@ -37,6 +37,7 @@ export function useGlobalSearch(content: SiteContent) {
const [questions, setQuestions] = useState<PublicQuestion[]>([])
const [episodeItems, setEpisodeItems] = useState<SearchResult[]>([])
const [nuggetsItems, setNuggetsItems] = useState<SearchResult[]>([])
const [newsletterItems, setNewsletterItems] = useState<SearchResult[]>([])
const [scriptResults, setScriptResults] = useState<SearchResult[]>([])
const scriptDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -95,6 +96,24 @@ export function useGlobalSearch(content: SiteContent) {
.catch(() => {})
}, [])
// Fetch published newsletter issues once on mount
useEffect(() => {
fetch('/api/newsletter/issues')
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { issues?: Array<{ id: string; slug: string; title: string; description?: string; sentAt?: string | null }> }) => {
const items: SearchResult[] = (data.issues ?? []).map(issue => ({
id: `newsletter-${issue.id}`,
type: 'newsletter' as const,
title: issue.title,
subtitle: 'Newsletter',
description: issue.description,
href: `/newsletter/${issue.slug}`,
}))
setNewsletterItems(items)
})
.catch(() => {})
}, [])
// Season number → book/study title, so script results can show "Romans" instead of "Season 3"
const seasonTitleMap = useMemo(() => {
const map = new Map<string, string>()
@@ -164,7 +183,7 @@ export function useGlobalSearch(content: SiteContent) {
return results
}, [content, questions])
const allFuseItems = useMemo(() => [...episodeItems, ...nuggetsItems, ...staticItems], [episodeItems, nuggetsItems, staticItems])
const allFuseItems = useMemo(() => [...episodeItems, ...nuggetsItems, ...newsletterItems, ...staticItems], [episodeItems, nuggetsItems, newsletterItems, staticItems])
const fuse = useMemo(() => new Fuse(allFuseItems, {
keys: [