243 lines
11 KiB
TypeScript
243 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
|
|
// Turns scripture references ("Titus 1:5-9") in answer text into hover/tap verse popups and URLs into links.
|
|
|
|
function escapeRegExp(value: string) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
|
|
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 <mark key={`mark-${index}`} className="qa-highlight">{part}</mark>
|
|
}
|
|
return <span key={`plain-${index}`}>{part}</span>
|
|
})
|
|
}
|
|
|
|
function renderTextWithLinks(text: string, highlightQuery = '') {
|
|
|
|
const BOOK_ID_MAP: Record<string, string> = {
|
|
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 (
|
|
<span key={`link-${index}`}>
|
|
<a href={safeHref} target="_blank" rel="noopener noreferrer">{safeHref}</a>
|
|
{trailing}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const parsed = parseScriptureRef(part.replace(/\.$/, ''))
|
|
if (parsed) {
|
|
return <ScriptureTooltip key={`scripture-${index}`} refText={part} bookId={parsed.bookId} chapter={parsed.chapter} verseStart={parsed.verseStart} verseEnd={parsed.verseEnd} />
|
|
}
|
|
|
|
return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</span>
|
|
})
|
|
}
|
|
|
|
interface VerseData {
|
|
verse: number
|
|
value: string
|
|
}
|
|
|
|
// Maps helloao API book IDs → bible.com book codes
|
|
const BIBLE_COM_BOOK_IDS: Record<string, string> = {
|
|
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<VerseData[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState(false)
|
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
|
const wrapRef = useRef<HTMLSpanElement>(null)
|
|
const btnRef = useRef<HTMLButtonElement>(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])
|
|
|
|
// Compute fixed position from the trigger button so the popup escapes overflow:hidden parents
|
|
useEffect(() => {
|
|
if (!open || !btnRef.current) { setPos(null); return }
|
|
const rect = btnRef.current.getBoundingClientRect()
|
|
const POPUP_W = 360
|
|
const left = Math.max(8, Math.min(rect.left, window.innerWidth - POPUP_W - 12))
|
|
setPos({ top: rect.bottom + 8, left })
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
function handleClickOutside(e: MouseEvent) {
|
|
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false)
|
|
}
|
|
function handleDismiss() { setOpen(false) }
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
window.addEventListener('scroll', handleDismiss, { passive: true, capture: true })
|
|
window.addEventListener('resize', handleDismiss)
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside)
|
|
window.removeEventListener('scroll', handleDismiss, { capture: true })
|
|
window.removeEventListener('resize', handleDismiss)
|
|
}
|
|
}, [open])
|
|
|
|
return (
|
|
<span ref={wrapRef} className="scripture-ref-wrap">
|
|
<button
|
|
ref={btnRef}
|
|
type="button"
|
|
className="scripture-ref"
|
|
onClick={() => setOpen(o => !o)}
|
|
aria-expanded={open}
|
|
title={`View ${refText}`}
|
|
>
|
|
{refText}
|
|
</button>
|
|
{open && pos && (
|
|
<span
|
|
className="scripture-popup"
|
|
role="tooltip"
|
|
style={{ position: 'fixed', top: pos.top, left: pos.left, bottom: 'auto' }}
|
|
>
|
|
<span className="scripture-popup-header">
|
|
<strong>{refText}</strong>
|
|
<button type="button" className="scripture-popup-close" onClick={() => setOpen(false)} aria-label="Close">✕</button>
|
|
</span>
|
|
{loading && <span className="scripture-popup-body">Loading…</span>}
|
|
{error && <span className="scripture-popup-body scripture-popup-error">Could not load verse.</span>}
|
|
{!loading && !error && verses.length === 0 && <span className="scripture-popup-body">Verse not found.</span>}
|
|
{!loading && !error && verses.map(v => (
|
|
<span key={v.verse} className="scripture-popup-body">
|
|
{verseStart !== verseEnd && <sup>{v.verse} </sup>}{v.value}
|
|
</span>
|
|
))}
|
|
<span className="scripture-popup-footer">
|
|
<a
|
|
href={`https://www.bible.com/bible/3034/${BIBLE_COM_BOOK_IDS[bookId] ?? bookId}.${chapter}.BSB`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="scripture-popup-link"
|
|
>
|
|
Read on Bible.com ↗
|
|
</a>
|
|
<span className="scripture-popup-attribution">Berean Standard Bible</span>
|
|
</span>
|
|
</span>
|
|
)}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
|
|
export function AnswerText({ text, highlight = '' }: { text: string; highlight?: string }) {
|
|
return <>{renderTextWithLinks(text, highlight)}</>
|
|
}
|