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:
nmemmertandClaude Sonnet 4.6 committed 2026-06-09 09:10:29 -04:00
1 parent 9ad24df626
commit c4645e3475
24 files changed
+2991 -76

No files matched your search

+165
View File
@@ -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 }
}