import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' interface PublicQuestion { id: string firstName: string question: string answer: string topic?: string submittedAt?: string answeredAt?: string upvotes?: number pinned?: boolean } interface DecoratedQuestion extends PublicQuestion { _topic: string _tags: string[] _helpful: number _semanticScore: number } type SortMode = 'relevance' | 'newest' | 'oldest' | 'helpful' interface EngagementCounts { shares: number related: number expands: number } type EngagementMap = Record const QA_PAGE_SIZE = 8 const ENGAGEMENT_STORAGE_KEY = 'qa-engagement-v1' const MAX_TAGS_PER_QUESTION = 4 const MAX_TAGS_VISIBLE = 10 const STOP_WORDS = new Set([ 'about', 'after', 'again', 'also', 'always', 'among', 'appears', 'around', 'been', 'before', 'being', 'between', 'both', 'could', 'does', 'each', 'every', 'from', 'have', 'into', 'just', 'like', 'many', 'more', 'most', 'much', 'must', 'other', 'over', 'same', 'some', 'such', 'than', 'that', 'their', 'there', 'these', 'they', 'this', 'those', 'through', 'very', 'what', 'when', 'where', 'which', 'while', 'will', 'with', 'would', 'your', 'you', 'the', 'and', 'for', 'are', 'but', 'not', 'too', 'can', 'how', 'why', 'who', 'was', 'were', 'into', 'upon', 'then', 'them', 'ours', 'ourselves', 'himself', 'herself', 'because', 'therefore', 'really', 'simply', 'right', 'still', 'even', 'today', 'episode', 'episodes', 'verse', 'verses', ]) const TAG_LEXICON = [ 'bible', 'scripture', 'faith', 'grace', 'hope', 'salvation', 'gospel', 'mercy', 'obedience', 'leadership', 'elders', 'church', 'doctrine', 'discipleship', 'prayer', 'family', 'politics', 'translation', 'reading', 'study', 'titus', 'christian', ] const SYNONYM_MAP: Record = { faith: ['belief', 'trust'], trust: ['faith', 'belief'], grace: ['mercy', 'favor'], mercy: ['grace', 'compassion'], hope: ['expectation', 'future'], love: ['charity', 'care'], sin: ['wrong', 'evil'], prayer: ['pray'], pray: ['prayer'], bible: ['scripture', 'word'], scripture: ['bible', 'word'], church: ['fellowship', 'body'], leadership: ['elder', 'pastor'], politics: ['government', 'public'], family: ['home', 'household'], translation: ['version'], salvation: ['saved', 'redeemed'], } function escapeRegExp(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function normalizeToken(token: string) { return token.toLowerCase().replace(/[^a-z0-9]/g, '').trim() } function tokenize(text: string) { return text .toLowerCase() .replace(/[^a-z0-9\s]/g, ' ') .split(/\s+/) .map(normalizeToken) .filter(token => token.length > 2) } function expandQueryTokens(tokens: string[]) { const expanded = new Set() for (const token of tokens) { expanded.add(token) const synonyms = SYNONYM_MAP[token] ?? [] for (const synonym of synonyms) expanded.add(synonym) } return expanded } function renderHighlightedText(text: string, query: string) { const trimmed = query.trim() if (!trimmed) return text const regex = new RegExp(`(${escapeRegExp(trimmed)})`, 'ig') const parts = text.split(regex) return parts.map((part, index) => { if (part.toLowerCase() === trimmed.toLowerCase()) { return {part} } return {part} }) } function renderTextWithLinks(text: string, highlightQuery = '') { const BOOK_ID_MAP: Record = { gen: 'GEN', genesis: 'GEN', exo: 'EXO', exodus: 'EXO', lev: 'LEV', leviticus: 'LEV', num: 'NUM', numbers: 'NUM', deut: 'DEU', deuteronomy: 'DEU', josh: 'JOS', joshua: 'JOS', judg: 'JDG', judges: 'JDG', ruth: 'RUT', '1 sam': 'SA1', '2 sam': 'SA2', '1 kgs': 'KI1', '1 kings': 'KI1', '2 kgs': 'KI2', '2 kings': 'KI2', '1 chr': 'CH1', '1 chron': 'CH1', '1 chronicles': 'CH1', '2 chr': 'CH2', '2 chron': 'CH2', '2 chronicles': 'CH2', ezra: 'EZR', neh: 'NEH', nehemiah: 'NEH', esth: 'EST', esther: 'EST', job: 'JOB', ps: 'PSA', psalms: 'PSA', psalm: 'PSA', prov: 'PRO', proverbs: 'PRO', eccl: 'ECC', ecclesiastes: 'ECC', song: 'SNG', 'song of sol': 'SNG', 'song of solomon': 'SNG', isa: 'ISA', isaiah: 'ISA', jer: 'JER', jeremiah: 'JER', lam: 'LAM', lamentations: 'LAM', ezek: 'EZK', ezekiel: 'EZK', dan: 'DAN', daniel: 'DAN', hos: 'HOS', hosea: 'HOS', joel: 'JOL', amos: 'AMO', obad: 'OBA', obadiah: 'OBA', jonah: 'JNA', mic: 'MIC', micah: 'MIC', nah: 'NAH', nahum: 'NAH', hab: 'HAB', habakkuk: 'HAB', zeph: 'ZEP', zephaniah: 'ZEP', hag: 'HAG', haggai: 'HAG', zech: 'ZEC', zechariah: 'ZEC', mal: 'MAL', malachi: 'MAL', matt: 'MAT', matthew: 'MAT', mark: 'MRK', luke: 'LUK', john: 'JHN', acts: 'ACT', rom: 'ROM', romans: 'ROM', '1 cor': 'CO1', '1 corinthians': 'CO1', '2 cor': 'CO2', '2 corinthians': 'CO2', gal: 'GAL', galatians: 'GAL', eph: 'EPH', ephesians: 'EPH', phil: 'PHP', philippians: 'PHP', col: 'COL', colossians: 'COL', '1 thess': 'TH1', '1 thessalonians': 'TH1', '2 thess': 'TH2', '2 thessalonians': 'TH2', '1 tim': 'TI1', '1 timothy': 'TI1', '2 tim': 'TI2', '2 timothy': 'TI2', titus: 'TIT', philem: 'PHM', philemon: 'PHM', heb: 'HEB', hebrews: 'HEB', jas: 'JAM', james: 'JAM', '1 pet': 'PE1', '1 peter': 'PE1', '2 pet': 'PE2', '2 peter': 'PE2', '1 john': 'JO1', '2 john': 'JO2', '3 john': 'JO3', jude: 'JDE', rev: 'REV', revelation: 'REV', } function parseScriptureRef(refText: string): { bookId: string; chapter: number; verseStart: number; verseEnd: number } | null { const match = refText.match(/^(.*?)\s+(\d+):(\d+)(?:-(\d+))?$/) if (!match) return null const [, bookRaw, chapterStr, verseStartStr, verseEndStr] = match const bookKey = bookRaw.toLowerCase().replace(/\.\s*/g, ' ').trim() const bookId = BOOK_ID_MAP[bookKey] if (!bookId) return null return { bookId, chapter: parseInt(chapterStr, 10), verseStart: parseInt(verseStartStr, 10), verseEnd: verseEndStr ? parseInt(verseEndStr, 10) : parseInt(verseStartStr, 10), } } // Split the text by both scripture refs and URLs const combined = /(https?:\/\/[^\s]+)|\b((?:(?:1|2|3)\s)?(?:Gen(?:esis)?|Exo(?:dus)?|Lev(?:iticus)?|Num(?:bers)?|Deut(?:eronomy)?|Josh(?:ua)?|Judg(?:es)?|Ruth|1\s?Sam|2\s?Sam|1\s?Kgs?|2\s?Kgs?|1\s?Chr(?:on)?|2\s?Chr(?:on)?|Ezra|Neh(?:emiah)?|Esth(?:er)?|Job|Ps(?:alms?)?|Prov(?:erbs)?|Eccl(?:esiastes)?|Song(?:\s?of\s?Sol(?:omon)?)?|Isa(?:iah)?|Jer(?:emiah)?|Lam(?:entations)?|Ezek(?:iel)?|Dan(?:iel)?|Hos(?:ea)?|Joel|Amos|Obad(?:iah)?|Jonah|Mic(?:ah)?|Nah(?:um)?|Hab(?:akkuk)?|Zeph(?:aniah)?|Hag(?:gai)?|Zech(?:ariah)?|Mal(?:achi)?|Matt(?:hew)?|Mark|Luke|John|Acts|Rom(?:ans)?|1\s?Cor(?:inthians)?|2\s?Cor(?:inthians)?|Gal(?:atians)?|Eph(?:esians)?|Phil(?:ippians)?|Col(?:ossians)?|1\s?Thess|2\s?Thess|1\s?Tim(?:othy)?|2\s?Tim(?:othy)?|Titus|Philem(?:on)?|Heb(?:rews)?|Jas(?:mes)?|1\s?Pet(?:er)?|2\s?Pet(?:er)?|1\s?John|2\s?John|3\s?John|Jude|Rev(?:elation)?)\.?\s+\d+:\d+(?:-\d+)?)\b/gi const parts: string[] = [] let lastIndex = 0 let m: RegExpExecArray | null // reset lastIndex for combined combined.lastIndex = 0 while ((m = combined.exec(text)) !== null) { if (m.index > lastIndex) parts.push(text.slice(lastIndex, m.index)) parts.push(m[0]) lastIndex = m.index + m[0].length } if (lastIndex < text.length) parts.push(text.slice(lastIndex)) return parts.map((part, index) => { if (/^https?:\/\//i.test(part)) { const safeHref = part.replace(/[),.;!?]+$/g, '') const trailing = part.slice(safeHref.length) return ( {safeHref} {trailing} ) } const parsed = parseScriptureRef(part.replace(/\.$/, '')) if (parsed) { return } return {renderHighlightedText(part, highlightQuery)} }) } interface VerseData { verse: number value: string } // Maps helloao API book IDs → bible.com book codes const BIBLE_COM_BOOK_IDS: Record = { GEN: 'GEN', EXO: 'EXO', LEV: 'LEV', NUM: 'NUM', DEU: 'DEU', JOS: 'JOS', JDG: 'JDG', RUT: 'RUT', SA1: '1SA', SA2: '2SA', KI1: '1KI', KI2: '2KI', CH1: '1CH', CH2: '2CH', EZR: 'EZR', NEH: 'NEH', EST: 'EST', JOB: 'JOB', PSA: 'PSA', PRO: 'PRO', ECC: 'ECC', SNG: 'SNG', ISA: 'ISA', JER: 'JER', LAM: 'LAM', EZK: 'EZK', DAN: 'DAN', HOS: 'HOS', JOL: 'JOL', AMO: 'AMO', OBA: 'OBA', JNA: 'JON', MIC: 'MIC', NAH: 'NAH', HAB: 'HAB', ZEP: 'ZEP', HAG: 'HAG', ZEC: 'ZEC', MAL: 'MAL', MAT: 'MAT', MRK: 'MRK', LUK: 'LUK', JHN: 'JHN', ACT: 'ACT', ROM: 'ROM', CO1: '1CO', CO2: '2CO', GAL: 'GAL', EPH: 'EPH', PHP: 'PHP', COL: 'COL', TH1: '1TH', TH2: '2TH', TI1: '1TI', TI2: '2TI', TIT: 'TIT', PHM: 'PHM', HEB: 'HEB', JAM: 'JAS', PE1: '1PE', PE2: '2PE', JO1: '1JN', JO2: '2JN', JO3: '3JN', JDE: 'JUD', REV: 'REV', } function ScriptureTooltip({ refText, bookId, chapter, verseStart, verseEnd }: { refText: string bookId: string chapter: number verseStart: number verseEnd: number }) { const [open, setOpen] = useState(false) const [verses, setVerses] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(false) const wrapRef = useRef(null) useEffect(() => { if (!open) return if (verses.length > 0) return setLoading(true) setError(false) fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`) .then(r => { if (!r.ok) throw new Error('Not found') return r.json() as Promise<{ chapter: { content: Array<{ type: string; number?: number; content?: Array<{ text?: string; poem?: number } | string> }> } }> }) .then(data => { const content = data?.chapter?.content ?? [] const found: VerseData[] = [] for (const item of content) { if (item.type === 'verse' && item.number != null && item.number >= verseStart && item.number <= verseEnd) { const text = (item.content ?? []) .map(c => (typeof c === 'string' ? c : (c.text ?? ''))) .join('') .trim() if (text) found.push({ verse: item.number, value: text }) } } setVerses(found) setLoading(false) }) .catch(() => { setError(true) setLoading(false) }) }, [open, bookId, chapter, verseStart, verseEnd, verses.length]) useEffect(() => { if (!open) return function handleClickOutside(e: MouseEvent) { if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { setOpen(false) } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, [open]) return ( {open && ( {refText} {loading && Loading…} {error && Could not load verse.} {!loading && !error && verses.length === 0 && Verse not found.} {!loading && !error && verses.map(v => ( {verseStart !== verseEnd && {v.verse} }{v.value} ))} Read on Bible.com ↗ Berean Standard Bible )} ) } // ─── old renderTextWithLinks removed, replaced above ─── function readEngagementFromStorage(): EngagementMap { try { const raw = window.localStorage.getItem(ENGAGEMENT_STORAGE_KEY) if (!raw) return {} const parsed = JSON.parse(raw) return typeof parsed === 'object' && parsed ? (parsed as EngagementMap) : {} } catch { return {} } } function truncateAnswer(text: string, limit = 260) { if (text.length <= limit) return text const sliced = text.slice(0, limit) const safeCut = sliced.lastIndexOf(' ') return `${sliced.slice(0, safeCut > 120 ? safeCut : limit).trim()}...` } function semanticScoreForQuestion(question: PublicQuestion, tags: string[], query: string) { const normalized = query.trim().toLowerCase() if (!normalized) return 0 const queryTokens = tokenize(normalized) if (queryTokens.length === 0) return 0 const expanded = expandQueryTokens(queryTokens) const questionTokens = new Set(tokenize(`${question.question} ${question.answer} ${question.topic ?? ''} ${tags.join(' ')}`)) let score = 0 for (const token of expanded) { if (questionTokens.has(token)) score += 2 } const lowerQuestion = question.question.toLowerCase() const lowerAnswer = question.answer.toLowerCase() const lowerTopic = (question.topic ?? '').toLowerCase() if (lowerQuestion.includes(normalized)) score += 6 if (lowerAnswer.includes(normalized)) score += 3 if (lowerTopic.includes(normalized)) score += 2 for (const token of queryTokens) { if (token.length < 4) continue if (lowerQuestion.includes(token)) score += 1 if (lowerAnswer.includes(token)) score += 0.5 } return score } function tokenizeForRelated(text: string) { return tokenize(text).filter(token => token.length > 3 && !STOP_WORDS.has(token)) } function extractAutoTags(question: PublicQuestion) { const source = `${question.question} ${question.answer}`.toLowerCase() const topicToken = normalizeToken(question.topic ?? '') const lexiconHits = TAG_LEXICON .filter(tag => source.includes(tag)) .filter(tag => tag !== topicToken) if (lexiconHits.length > 0) { return Array.from(new Set(lexiconHits)).slice(0, MAX_TAGS_PER_QUESTION) } const questionOnlyTokens = tokenize(question.question) .filter(token => token.length >= 5 && !STOP_WORDS.has(token) && token !== topicToken) .slice(0, MAX_TAGS_PER_QUESTION) return Array.from(new Set(questionOnlyTokens)) } export default function QASection() { const [questions, setQuestions] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [selectedTopic, setSelectedTopic] = useState(null) const [selectedTag, setSelectedTag] = useState(null) const [focusedQuestionId, setFocusedQuestionId] = useState(null) const [sortMode, setSortMode] = useState('relevance') const [compactMode, setCompactMode] = useState(false) const [page, setPage] = useState(0) const [engagement, setEngagement] = useState({}) const [expandedCompactAnswers, setExpandedCompactAnswers] = useState>({}) const [copiedQuestionId, setCopiedQuestionId] = useState(null) const [loading, setLoading] = useState(true) const [votedIds, setVotedIds] = useState>(() => { try { const raw = localStorage.getItem('qa-voted-ids-v1') return new Set(raw ? JSON.parse(raw) as string[] : []) } catch { return new Set() } }) const resultsTopRef = useRef(null) useEffect(() => { fetch('/api/questions') .then(r => (r.ok ? r.json() : Promise.reject(new Error('Failed to load questions')))) .then(data => { setQuestions((data as { questions: PublicQuestion[] }).questions ?? []) setLoading(false) }) .catch(() => { setLoading(false) }) }, []) async function handleUpvote(id: string) { if (votedIds.has(id)) return try { const res = await fetch(`/api/questions/${encodeURIComponent(id)}/upvote`, { method: 'POST' }) if (!res.ok) return const data = await res.json() as { upvotes: number } setQuestions(prev => prev.map(q => q.id === id ? { ...q, upvotes: data.upvotes } : q)) setVotedIds(prev => { const next = new Set(prev) next.add(id) localStorage.setItem('qa-voted-ids-v1', JSON.stringify([...next])) return next }) } catch { /* ignore */ } } useEffect(() => { setEngagement(readEngagementFromStorage()) }, []) useEffect(() => { window.localStorage.setItem(ENGAGEMENT_STORAGE_KEY, JSON.stringify(engagement)) }, [engagement]) const incrementEngagement = useCallback((id: string, metric: keyof EngagementCounts) => { setEngagement(prev => { const current = prev[id] ?? { shares: 0, related: 0, expands: 0 } return { ...prev, [id]: { ...current, [metric]: current[metric] + 1, }, } }) }, []) const normalizedSearch = searchQuery.trim().toLowerCase() const questionSet = useMemo(() => { return questions.map(question => { const topic = question.topic?.trim() || 'General' const tags = extractAutoTags(question) const counts = engagement[question.id] ?? { shares: 0, related: 0, expands: 0 } const helpful = counts.shares * 3 + counts.related * 2 + counts.expands const semantic = semanticScoreForQuestion(question, tags, normalizedSearch) return { ...question, _topic: topic, _tags: tags, _helpful: helpful, _semanticScore: semantic, } }) }, [engagement, normalizedSearch, questions]) useEffect(() => { const syncFromHash = () => { const hash = window.location.hash if (!hash.startsWith('#qa-')) return const id = decodeURIComponent(hash.slice(4)) const matched = questionSet.find(item => item.id === id) if (!matched) return setFocusedQuestionId(id) setSelectedTopic(matched._topic) window.setTimeout(() => { const element = document.getElementById(`qa-${id}`) element?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }, 80) } syncFromHash() window.addEventListener('hashchange', syncFromHash) return () => window.removeEventListener('hashchange', syncFromHash) }, [questionSet]) const topicCounts = useMemo(() => { const counts = new Map() for (const question of questionSet) { counts.set(question._topic, (counts.get(question._topic) ?? 0) + 1) } return Array.from(counts.entries()) .map(([topic, count]) => ({ topic, count })) .sort((a, b) => b.count - a.count || a.topic.localeCompare(b.topic)) }, [questionSet]) const tagCounts = useMemo(() => { const counts = new Map() for (const question of questionSet) { for (const tag of question._tags) { counts.set(tag, (counts.get(tag) ?? 0) + 1) } } return Array.from(counts.entries()) .map(([tag, count]) => ({ tag, count })) .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)) .slice(0, MAX_TAGS_VISIBLE) }, [questionSet]) const filteredQuestions = useMemo(() => { const output = questionSet.filter(question => { const matchesTopic = !selectedTopic || question._topic === selectedTopic const matchesTag = !selectedTag || question._tags.includes(selectedTag) const matchesSearch = !normalizedSearch || question._semanticScore > 0 return matchesTopic && matchesTag && matchesSearch }) const timestamp = (question: PublicQuestion) => { const value = question.answeredAt ?? question.submittedAt return value ? new Date(value).getTime() : 0 } output.sort((a, b) => { if (sortMode === 'newest') return timestamp(b) - timestamp(a) if (sortMode === 'oldest') return timestamp(a) - timestamp(b) if (sortMode === 'helpful') { if (b._helpful !== a._helpful) return b._helpful - a._helpful return timestamp(b) - timestamp(a) } if (b._semanticScore !== a._semanticScore) return b._semanticScore - a._semanticScore return timestamp(b) - timestamp(a) }) return output }, [normalizedSearch, questionSet, selectedTag, selectedTopic, sortMode]) const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)) const safePage = Math.min(page, totalPages - 1) const pageStart = safePage * QA_PAGE_SIZE const pageEnd = Math.min(pageStart + QA_PAGE_SIZE, filteredQuestions.length) const pagedQuestions = filteredQuestions.slice(pageStart, pageEnd) useEffect(() => { setPage(0) }, [normalizedSearch, selectedTag, selectedTopic, sortMode]) useEffect(() => { if (page > totalPages - 1) setPage(totalPages - 1) }, [page, totalPages]) useEffect(() => { if (!focusedQuestionId) return const timer = window.setTimeout(() => setFocusedQuestionId(null), 2200) return () => window.clearTimeout(timer) }, [focusedQuestionId]) useEffect(() => { if (!focusedQuestionId) return const index = filteredQuestions.findIndex(item => item.id === focusedQuestionId) if (index === -1) return const nextPage = Math.floor(index / QA_PAGE_SIZE) if (nextPage !== safePage) setPage(nextPage) }, [filteredQuestions, focusedQuestionId, safePage]) const goToPage = (nextPage: number) => { setPage(nextPage) window.setTimeout(() => { resultsTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, 40) } const handleSearch = (value: string) => { setSearchQuery(value) } const handleTopic = (topic: string | null) => { setSelectedTopic(topic) } const handleTag = (tag: string | null) => { setSelectedTag(tag) } const clearFilters = () => { setSelectedTopic(null) setSelectedTag(null) setSearchQuery('') setPage(0) } const buildQuoteCanvas = (question: DecoratedQuestion): HTMLCanvasElement => { const SIZE = 1080 const PADDING = 72 const canvas = document.createElement('canvas') canvas.width = SIZE canvas.height = SIZE const ctx = canvas.getContext('2d')! ctx.fillStyle = '#1a1208' ctx.fillRect(0, 0, SIZE, SIZE) ctx.fillStyle = '#c9a84c' ctx.fillRect(0, 0, SIZE, 6) function wrapText(text: string, maxWidth: number, font: string): string[] { ctx.font = font const words = text.split(' ') const lines: string[] = [] let line = '' for (const word of words) { const test = line ? `${line} ${word}` : word if (ctx.measureText(test).width > maxWidth && line) { lines.push(line); line = word } else { line = test } } if (line) lines.push(line) return lines } let y = PADDING + 40 ctx.fillStyle = '#c9a84c' ctx.font = 'bold 26px Georgia, serif' ctx.textAlign = 'center' ctx.fillText('✦ Verse by Verse with Nate ✦', SIZE / 2, y) y += 50 ctx.fillStyle = '#c9a84c' ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1) y += 36 ctx.fillStyle = '#c9a84c' ctx.font = 'bold 22px Georgia, serif' ctx.textAlign = 'left' ctx.fillText('Q', PADDING, y) y += 8 const qFont = 'italic 32px Georgia, serif' const qLines = wrapText(`"${question.question}"`, SIZE - PADDING * 2 - 20, qFont) ctx.fillStyle = '#f5ead2' ctx.font = qFont ctx.textAlign = 'left' const qLineH = 44 for (const line of qLines.slice(0, 5)) { y += qLineH; ctx.fillText(line, PADDING + 20, y) } y += 40 ctx.fillStyle = '#3a2d14' ctx.fillRect(PADDING, y, SIZE - PADDING * 2, 1) y += 36 ctx.fillStyle = '#c9a84c' ctx.font = 'bold 22px Georgia, serif' ctx.textAlign = 'left' ctx.fillText('A', PADDING, y) y += 8 const aFont = '28px Georgia, serif' const maxAHeight = SIZE - y - PADDING - 60 const aLineH = 40 const maxALines = Math.floor(maxAHeight / aLineH) const rawAnswer = question.answer.replace(/\*\*/g, '').replace(/\*/g, '').replace(/<[^>]+>/g, '') const aLines = wrapText(rawAnswer, SIZE - PADDING * 2 - 20, aFont).slice(0, maxALines) if (aLines.length > 0 && rawAnswer.split(' ').length > aLines.join(' ').split(' ').length) { aLines[aLines.length - 1] = aLines[aLines.length - 1].replace(/\s*\w+$/, '…') } ctx.fillStyle = '#e8d9b5' ctx.font = aFont for (const line of aLines) { y += aLineH; ctx.fillText(line, PADDING + 20, y) } ctx.fillStyle = '#8a7040' ctx.font = '20px Georgia, serif' ctx.textAlign = 'center' ctx.fillText(window.location.host + '/questions', SIZE / 2, SIZE - PADDING + 8) return canvas } const saveAsImage = (question: DecoratedQuestion) => { const canvas = buildQuoteCanvas(question) const link = document.createElement('a') link.download = `vbvn-qa-${question.id.slice(0, 8)}.png` link.href = canvas.toDataURL('image/png') link.click() } const shareImageTo = async (question: DecoratedQuestion, platform: 'x' | 'facebook') => { const canvas = buildQuoteCanvas(question) const shareUrl = getSocialUrl(question.id) const shareText = `Q: ${question.question.slice(0, 120)}${question.question.length > 120 ? '…' : ''}` // Try Web Share API with file (works on mobile — iOS Safari, Android Chrome) if (typeof navigator.share === 'function') { try { const blob = await new Promise((resolve, reject) => canvas.toBlob(b => b ? resolve(b) : reject(new Error('Canvas toBlob failed')), 'image/png') ) const file = new File([blob], `vbvn-qa-${question.id.slice(0, 8)}.png`, { type: 'image/png' }) if (navigator.canShare?.({ files: [file] })) { await navigator.share({ files: [file], text: shareText, url: shareUrl }) incrementEngagement(question.id, 'shares') return } } catch (err) { if ((err as DOMException)?.name === 'AbortError') return } } // Desktop fallback: download the image, then open the compose window const link = document.createElement('a') link.download = `vbvn-qa-${question.id.slice(0, 8)}.png` link.href = canvas.toDataURL('image/png') link.click() if (platform === 'x') { const tweetUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}` window.open(tweetUrl, '_blank', 'width=600,height=400,noopener') } else { const fbUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}` window.open(fbUrl, '_blank', 'width=600,height=400,noopener') } incrementEngagement(question.id, 'shares') } const getShareUrl = (id: string) => `${window.location.origin}/questions#qa-${encodeURIComponent(id)}` const getSocialUrl = (id: string) => `${window.location.origin}/questions/share/${encodeURIComponent(id)}` const shareQuestion = async (id: string) => { const shareUrl = getShareUrl(id) try { await navigator.clipboard.writeText(shareUrl) incrementEngagement(id, 'shares') setCopiedQuestionId(id) window.history.replaceState(null, '', `/questions#qa-${encodeURIComponent(id)}`) window.setTimeout(() => setCopiedQuestionId(curr => (curr === id ? null : curr)), 1800) } catch { window.prompt('Copy this link:', shareUrl) } } const formatDate = (value?: string) => { if (!value) return null const parsed = new Date(value) if (Number.isNaN(parsed.getTime())) return null return parsed.toLocaleDateString() } const toggleCompactAnswer = (id: string) => { const next = !expandedCompactAnswers[id] setExpandedCompactAnswers(prev => ({ ...prev, [id]: next })) if (next) incrementEngagement(id, 'expands') } const getRelatedQuestions = (current: DecoratedQuestion) => { const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`)) return questionSet .filter(candidate => candidate.id !== current.id) .map(candidate => { const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`) const overlap = candidateTokens.filter(token => currentTokens.has(token)).length const sameTopic = current._topic === candidate._topic const score = overlap + (sameTopic ? 5 : 0) return { candidate, score } }) .filter(item => item.score > 0) .sort((a, b) => b.score - a.score) .slice(0, 3) .map(item => item.candidate) } const topSuggestedTopic = topicCounts[0]?.topic ?? null const mostHelpfulQuestion = [...questionSet].sort((a, b) => b._helpful - a._helpful)[0] ?? null return (

Have a question about Scripture or the podcast?

Submit a Question →
{loading ? (
{[0, 1, 2].map(i => (
))}
) : questions.length === 0 ? (

No questions have been answered yet. Submit yours below!

) : ( <>
{topicCounts.map(({ topic }) => ( ))}
{tagCounts.map(({ tag }) => ( ))}
{(searchQuery || selectedTopic || selectedTag) && (
{searchQuery && ( )} {selectedTopic && ( )} {selectedTag && ( )}
)} {filteredQuestions.length === 0 ? (

No matches for this search yet.

{topSuggestedTopic && (

Try browsing questions.

)} {mostHelpfulQuestion && (

Or jump to .

)}

Submit your question and Nate may add it here.

) : ( <>

{filteredQuestions.length} result{filteredQuestions.length === 1 ? '' : 's'} {normalizedSearch && for "{normalizedSearch}"} {filteredQuestions.length > 0 && · Showing {pageStart + 1}-{pageEnd}}

)} )}
) }