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([]) const [episodeItems, setEpisodeItems] = useState([]) const [nuggetsItems, setNuggetsItems] = useState([]) const [newsletterItems, setNewsletterItems] = useState([]) const [scriptResults, setScriptResults] = useState([]) const scriptDebounceRef = useRef | 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() 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 "-" 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() 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 } }