Files
Siteforge/src/hooks/useGlobalSearch.ts
T
nmemmertandClaude Sonnet 5.5 752611d121
Publish Container / build (push) Successful in 16m51s
Episode scripts: file by season + episode so numbers can repeat per book; search labels for current series; v2.9.6
- 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>
2026-10-08 15:31:11 -04:00

249 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}