Enhance admin dashboard and scripture linking

This commit is contained in:
nmemmert
2026-05-12 09:12:55 -04:00
parent 0703a802ff
commit becc1b5b27
4 changed files with 976 additions and 21 deletions
+186 -13
View File
@@ -109,27 +109,200 @@ function renderHighlightedText(text: string, query: string) {
}
function renderTextWithLinks(text: string, highlightQuery = '') {
const parts = text.split(/(https?:\/\/[^\s]+)/g)
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)) {
return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</span>
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 safeHref = part.replace(/[),.;!?]+$/g, '')
const trailing = part.slice(safeHref.length)
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={`link-${index}`}>
<a href={safeHref} target="_blank" rel="noopener noreferrer">
{safeHref}
</a>
{trailing}
</span>
)
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 wrapRef = useRef<HTMLSpanElement>(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 (
<span ref={wrapRef} className="scripture-ref-wrap">
<button
type="button"
className="scripture-ref"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
title={`View ${refText}`}
>
{refText}
</button>
{open && (
<span className="scripture-popup" role="tooltip">
<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>
)
}
// ─── old renderTextWithLinks removed, replaced above ───
function readEngagementFromStorage(): EngagementMap {
try {
const raw = window.localStorage.getItem(ENGAGEMENT_STORAGE_KEY)