Publish Container / build (push) Successful in 16m51s
- Scripts keyed "<season>-<episode>" (legacy ones migrate on first start; unseasoned ones keep their plain key) - Season backfill handles episode numbers shared across seasons (title match fallback) - Fix: "Backfill seasons from RSS" was swallowed by the upload route - Global search labels script results with the title of the series playing now (both lanes), not just finished books Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
249 lines
9.4 KiB
TypeScript
249 lines
9.4 KiB
TypeScript
import { useMemo, useState, useEffect, useRef } from 'react'
|
||
import Fuse from 'fuse.js'
|
||
import type { SiteContent } from '../content'
|
||
import { getAllHighlights } from '../content'
|
||
|
||
export type SearchResultType = 'episode' | 'question' | 'resource' | 'series' | 'newsletter'
|
||
|
||
export interface SearchResult {
|
||
id: string
|
||
type: SearchResultType
|
||
title: string
|
||
subtitle?: string
|
||
description?: string
|
||
href: string
|
||
/** Snippet from script text — only present on script-matched episode results */
|
||
scriptSnippet?: string
|
||
}
|
||
|
||
interface PublicQuestion {
|
||
id: string
|
||
firstName?: string
|
||
question: string
|
||
answer?: string
|
||
topic?: string
|
||
}
|
||
|
||
interface ScriptSearchResult {
|
||
key?: string
|
||
episodeNumber: string
|
||
title: string
|
||
filename: string
|
||
season: string | null
|
||
snippet: string
|
||
score: number
|
||
}
|
||
|
||
export function useGlobalSearch(content: SiteContent) {
|
||
const [query, setQuery] = useState('')
|
||
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)
|
||
|
||
// Fetch questions once on mount
|
||
useEffect(() => {
|
||
fetch('/api/questions')
|
||
.then(r => r.ok ? r.json() : Promise.reject())
|
||
.then((data: { questions?: PublicQuestion[] }) => {
|
||
setQuestions(Array.isArray(data.questions) ? data.questions : [])
|
||
})
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
// Fetch full episode list once on mount (for title/description matching)
|
||
useEffect(() => {
|
||
fetch('/api/episodes/all')
|
||
.then(r => r.ok ? r.json() : Promise.reject())
|
||
.then((data: { episodes?: Array<{ title: string; link: string; description?: string; episode?: string }> }) => {
|
||
const eps: SearchResult[] = (data.episodes ?? []).map(ep => {
|
||
// Check if there's a matching podcastFeaturedLink highlight page for this episode number
|
||
const highlight = getAllHighlights(content).find(
|
||
f => f.episodeNumber && ep.episode && String(f.episodeNumber) === String(ep.episode)
|
||
)
|
||
const hasRichPage = highlight && !!(
|
||
highlight.embedUrl || highlight.showNotes || (highlight.discussionQuestions ?? []).length > 0
|
||
)
|
||
return {
|
||
id: `episode-${ep.episode ?? ep.title}`,
|
||
type: 'episode' as const,
|
||
title: ep.title,
|
||
subtitle: ep.episode ? `Episode ${ep.episode}` : 'Episode',
|
||
description: ep.description,
|
||
href: hasRichPage ? `/episodes/${highlight!.id}` : (ep.link || '/episodes'),
|
||
}
|
||
})
|
||
setEpisodeItems(eps)
|
||
})
|
||
.catch(() => {})
|
||
}, [content])
|
||
|
||
// Fetch nuggets episodes once on mount
|
||
useEffect(() => {
|
||
fetch('/api/nuggets/episodes/all')
|
||
.then(r => r.ok ? r.json() : Promise.reject())
|
||
.then((data: { episodes?: Array<{ title: string; link: string; description?: string; episode?: string }> }) => {
|
||
const eps: SearchResult[] = (data.episodes ?? []).map(ep => ({
|
||
id: `nuggets-${ep.episode ?? ep.title}`,
|
||
type: 'episode' as const,
|
||
title: ep.title,
|
||
subtitle: ep.episode ? `Nuggets Ep. ${ep.episode}` : 'Nuggets',
|
||
description: ep.description,
|
||
href: '/nuggets',
|
||
}))
|
||
setNuggetsItems(eps)
|
||
})
|
||
.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>()
|
||
for (const book of content.finishedBooks ?? []) {
|
||
if (book.season != null && book.title) map.set(String(book.season), book.title)
|
||
}
|
||
// The series playing now (one or two at a time), so their scripts show a title too
|
||
if (content.seriesSeason != null && content.seriesTitle) map.set(String(content.seriesSeason), content.seriesTitle)
|
||
if (content.secondSeries?.season != null && content.secondSeries.title) map.set(String(content.secondSeries.season), content.secondSeries.title)
|
||
return map
|
||
}, [content.finishedBooks, content.seriesSeason, content.seriesTitle, content.secondSeries])
|
||
|
||
// Debounced server-side script search — runs whenever query changes
|
||
useEffect(() => {
|
||
if (scriptDebounceRef.current) clearTimeout(scriptDebounceRef.current)
|
||
if (!query.trim() || query.trim().length < 2) {
|
||
setScriptResults([])
|
||
return
|
||
}
|
||
scriptDebounceRef.current = setTimeout(() => {
|
||
fetch(`/api/episode-scripts/search?q=${encodeURIComponent(query.trim())}`)
|
||
.then(r => r.ok ? r.json() : Promise.reject())
|
||
.then((data: { results?: ScriptSearchResult[] }) => {
|
||
const results = (data.results ?? []).map(r => {
|
||
const seasonLabel = r.season ? (seasonTitleMap.get(r.season) ?? `Season ${r.season}`) : null
|
||
return {
|
||
id: `script-${r.key ?? r.episodeNumber}`,
|
||
type: 'episode' as const,
|
||
title: r.title,
|
||
subtitle: [`Episode ${r.episodeNumber}`, seasonLabel, 'Script'].filter(Boolean).join(' · '),
|
||
description: r.snippet || undefined,
|
||
scriptSnippet: r.snippet || undefined,
|
||
href: `/episodes`,
|
||
}
|
||
})
|
||
setScriptResults(results)
|
||
})
|
||
.catch(() => setScriptResults([]))
|
||
}, 200)
|
||
}, [query, seasonTitleMap])
|
||
|
||
// Build the Fuse corpus: episodes + resources + series + Q&A
|
||
const staticItems: SearchResult[] = useMemo(() => {
|
||
const results: SearchResult[] = []
|
||
|
||
for (const link of content.customLinks ?? []) {
|
||
if (link.placement === 'resources' || link.placement === 'otherSites' || link.placement === 'externalSites') {
|
||
results.push({
|
||
id: `resource-${link.id}`,
|
||
type: 'resource',
|
||
title: link.label,
|
||
subtitle: link.placement === 'resources' ? 'Download' : 'Resource',
|
||
description: link.description,
|
||
href: `/downloads/custom--${link.id}`,
|
||
})
|
||
}
|
||
}
|
||
|
||
for (const q of questions) {
|
||
results.push({
|
||
id: `qa-${q.id}`,
|
||
type: 'question',
|
||
title: q.question,
|
||
subtitle: q.topic ?? 'Q&A',
|
||
description: q.answer ? q.answer.slice(0, 120) : undefined,
|
||
href: `/questions`,
|
||
})
|
||
}
|
||
|
||
return results
|
||
}, [content, questions])
|
||
|
||
const allFuseItems = useMemo(() => [...episodeItems, ...nuggetsItems, ...newsletterItems, ...staticItems], [episodeItems, nuggetsItems, newsletterItems, staticItems])
|
||
|
||
const fuse = useMemo(() => new Fuse(allFuseItems, {
|
||
keys: [
|
||
{ name: 'title', weight: 2 },
|
||
{ name: 'subtitle', weight: 0.5 },
|
||
{ name: 'description', weight: 1 },
|
||
],
|
||
threshold: 0.35,
|
||
minMatchCharLength: 2,
|
||
includeScore: true,
|
||
}), [allFuseItems])
|
||
|
||
const results = useMemo(() => {
|
||
const q = query.trim()
|
||
if (!q || q.length < 2) return []
|
||
|
||
// Fuse's own score is 0 (perfect) to 1 (worst) — flip it so higher is better,
|
||
// and comparable to the script-search endpoint's 0–1 "higher is better" score.
|
||
const fuseMatches = fuse.search(q).slice(0, 10).map(r => ({ item: r.item, score: 1 - (r.score ?? 0) }))
|
||
|
||
// Episode/nuggets/script ids all end in "-<episodeNumber>" when a number is known;
|
||
// use that to merge a title match and a script-content match into one row instead
|
||
// of showing the same episode twice.
|
||
const episodeNumberFromId = (id: string): string | null => {
|
||
const m = /^(?:episode|nuggets|script)-(\d+)$/.exec(id)
|
||
return m ? m[1] : null
|
||
}
|
||
|
||
const merged = [...fuseMatches]
|
||
const indexByEpisode = new Map<string, number>()
|
||
merged.forEach((m, i) => {
|
||
const num = episodeNumberFromId(m.item.id)
|
||
if (num) indexByEpisode.set(num, i)
|
||
})
|
||
|
||
for (const scriptItem of scriptResults) {
|
||
const num = episodeNumberFromId(scriptItem.id)
|
||
const existingIdx = num ? indexByEpisode.get(num) : undefined
|
||
if (existingIdx != null) {
|
||
// Same episode already matched by title/description — attach the script
|
||
// snippet so the row shows why it matched, and boost its relevance.
|
||
const existing = merged[existingIdx]
|
||
merged[existingIdx] = {
|
||
item: existing.item.scriptSnippet ? existing.item : { ...existing.item, scriptSnippet: scriptItem.scriptSnippet },
|
||
score: Math.max(existing.score, 0.9),
|
||
}
|
||
} else {
|
||
merged.push({ item: scriptItem, score: 0.9 })
|
||
if (num) indexByEpisode.set(num, merged.length - 1)
|
||
}
|
||
}
|
||
|
||
return merged.sort((a, b) => b.score - a.score).slice(0, 8).map(m => m.item)
|
||
}, [fuse, query, scriptResults])
|
||
|
||
return { query, setQuery, results }
|
||
}
|