Add cross-site improvements across three phases
Phase 1 — Quick wins: - Image lazy-loading on series/resource cards - Newsletter signup added to Episodes page (before highlights) - Per-route meta tags via usePageMeta hook (title, og:title, og:description) - Breadcrumbs on study index, section, and notes pages - SVG completion checkmark badges on study section list - Analytics time-range filter (7d / 30d / 90d) in admin panel Phase 2 — Medium features: - Related episodes on archived series detail pages - Resource library two-tier filter (type + tag chips) - Global search (Fuse.js) moved below sticky header as full-width bar - Q&A anonymous upvoting with localStorage dedup + admin pin/unpin - Study enrollment funnel tracking (firstVisitAt, firstCompletionAt) with funnel chart in analytics Phase 3 — Larger features: - Study section comments (auto-approve for enrolled users, admin moderation panel) - Study completion certificate (canvas render, PNG download, shareable public URL) - Episode script full-text search (mammoth docx extraction, server-side search, admin upload UI) - Reflection questions renamed from Discussion Questions; quiz answers can be shared to section discussion - Public certificate route at /certificate/:token with og meta tags - Comment moderation panel added to admin under Manage > Study Comments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ad24df626
commit
c4645e3475
24 files changed
+2991
-76
No files matched your search
@@ -0,0 +1,165 @@
|
||||
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 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 {
|
||||
episodeNumber: string
|
||||
title: string
|
||||
filename: string
|
||||
snippet: string
|
||||
score: number
|
||||
}
|
||||
|
||||
export function useGlobalSearch(content: SiteContent) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [questions, setQuestions] = useState<PublicQuestion[]>([])
|
||||
const [episodeItems, setEpisodeItems] = 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 => ({
|
||||
id: `episode-${ep.episode ?? ep.title}`,
|
||||
type: 'episode' as const,
|
||||
title: ep.title,
|
||||
subtitle: ep.episode ? `Episode ${ep.episode}` : 'Episode',
|
||||
description: ep.description,
|
||||
href: ep.link || '/episodes',
|
||||
}))
|
||||
setEpisodeItems(eps)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// 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 => ({
|
||||
id: `script-${r.episodeNumber}`,
|
||||
type: 'episode' as const,
|
||||
title: r.title,
|
||||
subtitle: `Episode ${r.episodeNumber} · Script`,
|
||||
description: r.snippet,
|
||||
scriptSnippet: r.snippet,
|
||||
href: `/episodes`,
|
||||
}))
|
||||
setScriptResults(results)
|
||||
})
|
||||
.catch(() => setScriptResults([]))
|
||||
}, 200)
|
||||
}, [query])
|
||||
|
||||
// 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/${link.id}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const series of content.archivedSeries ?? []) {
|
||||
results.push({
|
||||
id: `series-${series.id}`,
|
||||
type: 'series',
|
||||
title: series.title,
|
||||
subtitle: series.label || 'Archived Series',
|
||||
description: series.description,
|
||||
href: `/episodes#${series.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, ...staticItems], [episodeItems, 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 []
|
||||
|
||||
const fuseMatches = fuse.search(q).slice(0, 6).map(r => r.item)
|
||||
|
||||
// Merge script results — deduplicate by episode id (prefer script match if already in fuse)
|
||||
const fuseIds = new Set(fuseMatches.map(r => r.id))
|
||||
const dedupedScripts = scriptResults.filter(r => !fuseIds.has(r.id))
|
||||
|
||||
// Interleave: fuse results first, then script-only hits, total cap 8
|
||||
return [...fuseMatches, ...dedupedScripts].slice(0, 8)
|
||||
}, [fuse, query, scriptResults])
|
||||
|
||||
return { query, setQuery, results }
|
||||
}
|
||||
Reference in new issue
Block a user