Enhance admin dashboard and scripture linking
This commit is contained in:
+186
-13
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user