Fetch BSB passage text from Bolls.life API in study lesson view; v1.1.35
Adds /api/bible-passage server route that proxies the Berean Standard Bible from Bolls.life per-verse, with in-memory caching. Study lesson scripture block now displays live BSB text instead of static passageText. Adds bibleBook field to StudyProgram so admins can set the book name explicitly when the study slug doesn't match (e.g. a study titled "ephesians-part-2"). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,8 @@
|
|||||||
},
|
},
|
||||||
"timeOnPage": {
|
"timeOnPage": {
|
||||||
"/": {
|
"/": {
|
||||||
"totalSeconds": 9,
|
"totalSeconds": 51,
|
||||||
"count": 2
|
"count": 4
|
||||||
},
|
},
|
||||||
"/episodes": {
|
"/episodes": {
|
||||||
"totalSeconds": 318967,
|
"totalSeconds": 318967,
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"outboundClicks": {},
|
"outboundClicks": {},
|
||||||
|
"linkClicks": {},
|
||||||
"utmSources": {},
|
"utmSources": {},
|
||||||
"searchQueries": {},
|
"searchQueries": {},
|
||||||
"notFound": {},
|
"notFound": {},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"users": {},
|
"users": {},
|
||||||
"updatedAt": "2026-08-10T12:37:17.805Z"
|
"updatedAt": "2026-08-10T14:07:00.476Z"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "siteforge",
|
"name": "siteforge",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.34",
|
"version": "1.1.35",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { register as registerDownloads } from './server/routes/downloads.js'
|
|||||||
import { register as registerEpisodes } from './server/routes/episodes.js'
|
import { register as registerEpisodes } from './server/routes/episodes.js'
|
||||||
import { register as registerFeed } from './server/routes/feed.js'
|
import { register as registerFeed } from './server/routes/feed.js'
|
||||||
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
import { register as registerQrCodes } from './server/routes/qr-codes.js'
|
||||||
|
import { register as registerBiblePassage } from './server/routes/bible-passage.js'
|
||||||
import { register as registerPublic } from './server/routes/public.js'
|
import { register as registerPublic } from './server/routes/public.js'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
@@ -110,6 +111,7 @@ registerDownloads(app)
|
|||||||
registerEpisodes(app)
|
registerEpisodes(app)
|
||||||
registerFeed(app)
|
registerFeed(app)
|
||||||
registerQrCodes(app)
|
registerQrCodes(app)
|
||||||
|
registerBiblePassage(app)
|
||||||
|
|
||||||
// Hit-counting middleware (must come before public routes)
|
// Hit-counting middleware (must come before public routes)
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
const passageCache = new Map()
|
||||||
|
|
||||||
|
const BOLLS_BOOK_MAP = {
|
||||||
|
genesis: 1, exodus: 2, leviticus: 3, numbers: 4, deuteronomy: 5,
|
||||||
|
joshua: 6, judges: 7, ruth: 8, '1samuel': 9, '2samuel': 10,
|
||||||
|
'1kings': 11, '2kings': 12, '1chronicles': 13, '2chronicles': 14,
|
||||||
|
ezra: 15, nehemiah: 16, esther: 17, job: 18, psalms: 19, proverbs: 20,
|
||||||
|
ecclesiastes: 21, songofsolomon: 22, isaiah: 23, jeremiah: 24,
|
||||||
|
lamentations: 25, ezekiel: 26, daniel: 27, hosea: 28, joel: 29,
|
||||||
|
amos: 30, obadiah: 31, jonah: 32, micah: 33, nahum: 34, habakkuk: 35,
|
||||||
|
zephaniah: 36, haggai: 37, zechariah: 38, malachi: 39,
|
||||||
|
matthew: 40, mark: 41, luke: 42, john: 43, acts: 44,
|
||||||
|
romans: 45, '1corinthians': 46, '2corinthians': 47, galatians: 48,
|
||||||
|
ephesians: 49, philippians: 50, colossians: 51, '1thessalonians': 52,
|
||||||
|
'2thessalonians': 53, '1timothy': 54, '2timothy': 55, titus: 56,
|
||||||
|
philemon: 57, hebrews: 58, james: 59, '1peter': 60, '2peter': 61,
|
||||||
|
'1john': 62, '2john': 63, '3john': 64, jude: 65, revelation: 66,
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReference(reference) {
|
||||||
|
const match = reference.match(/^(\d+):(\d+)(?:-(\d+))?$/)
|
||||||
|
if (!match) return null
|
||||||
|
return {
|
||||||
|
chapter: parseInt(match[1], 10),
|
||||||
|
verseStart: parseInt(match[2], 10),
|
||||||
|
verseEnd: match[3] ? parseInt(match[3], 10) : parseInt(match[2], 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function register(app) {
|
||||||
|
app.get('/api/bible-passage', async (req, res) => {
|
||||||
|
const book = String(req.query.book ?? '').trim().toLowerCase().replace(/[\s-]/g, '')
|
||||||
|
const reference = String(req.query.reference ?? '').trim()
|
||||||
|
|
||||||
|
const bookNum = BOLLS_BOOK_MAP[book]
|
||||||
|
if (!bookNum) {
|
||||||
|
res.status(400).json({ message: 'Unknown book.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseReference(reference)
|
||||||
|
if (!parsed) {
|
||||||
|
res.status(400).json({ message: 'Invalid reference format. Expected format: chapter:verseStart-verseEnd' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `BSB:${bookNum}:${parsed.chapter}:${parsed.verseStart}:${parsed.verseEnd}`
|
||||||
|
if (passageCache.has(cacheKey)) {
|
||||||
|
res.json({ text: passageCache.get(cacheKey) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verseNums = []
|
||||||
|
for (let v = parsed.verseStart; v <= parsed.verseEnd; v++) verseNums.push(v)
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
verseNums.map(v =>
|
||||||
|
fetch(`https://bolls.life/get-verse/BSB/${bookNum}/${parsed.chapter}/${v}/`, { signal: AbortSignal.timeout(8000) })
|
||||||
|
.then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
res.status(404).json({ message: 'Passage not found.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = results.map(v => `v. ${v.verse} ${String(v.text).trim()}`).join('\n\n')
|
||||||
|
passageCache.set(cacheKey, text)
|
||||||
|
res.json({ text })
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ message: 'Unable to fetch passage right now.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4285,6 +4285,11 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
|||||||
<label htmlFor={`study-slug-${study.id}`}>Slug</label>
|
<label htmlFor={`study-slug-${study.id}`}>Slug</label>
|
||||||
<input id={`study-slug-${study.id}`} type="text" value={study.slug} placeholder="colossians" onChange={e => updateStudyProgram(study.id, 'slug', e.target.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'))} />
|
<input id={`study-slug-${study.id}`} type="text" value={study.slug} placeholder="colossians" onChange={e => updateStudyProgram(study.id, 'slug', e.target.value.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-'))} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="admin-field">
|
||||||
|
<label htmlFor={`study-bible-book-${study.id}`}>Bible Book (for passage lookup)</label>
|
||||||
|
<input id={`study-bible-book-${study.id}`} type="text" value={study.bibleBook ?? ''} placeholder="colossians (leave blank to use slug)" onChange={e => updateStudyProgram(study.id, 'bibleBook', e.target.value.trim().toLowerCase().replace(/\s+/g, ''))} />
|
||||||
|
<p className="admin-stats-note" style={{ marginTop: '0.25rem' }}>Lowercase book name used to fetch BSB passage text. E.g. colossians, ephesians, 1corinthians. Leave blank if the slug matches the book name.</p>
|
||||||
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label htmlFor={`study-status-${study.id}`}>Status</label>
|
<label htmlFor={`study-status-${study.id}`}>Status</label>
|
||||||
<select id={`study-status-${study.id}`} value={study.status} onChange={e => updateStudyProgram(study.id, 'status', e.target.value)}>
|
<select id={`study-status-${study.id}`} value={study.status} onChange={e => updateStudyProgram(study.id, 'status', e.target.value)}>
|
||||||
|
|||||||
+28
-1
@@ -1506,6 +1506,8 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
|
const [bannerDismissed, setBannerDismissed] = useState(() => localStorage.getItem('study-banner-dismissed-v1') === '1')
|
||||||
const [autoSaveMsg, setAutoSaveMsg] = useState('')
|
const [autoSaveMsg, setAutoSaveMsg] = useState('')
|
||||||
const [_checkpointReflection, _setCheckpointReflection] = useState('')
|
const [_checkpointReflection, _setCheckpointReflection] = useState('')
|
||||||
|
const [bsbPassage, setBsbPassage] = useState<string | null>(null)
|
||||||
|
const [bsbPassageLoading, setBsbPassageLoading] = useState(false)
|
||||||
|
|
||||||
const savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length
|
const savedNoteCount = Object.values(noteMap).filter(v => v?.trim()).length
|
||||||
const lessonAudioEmbedUrl = useMemo(() => {
|
const lessonAudioEmbedUrl = useMemo(() => {
|
||||||
@@ -1546,6 +1548,21 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!section?.reference || !currentStudySlug) return
|
||||||
|
let cancelled = false
|
||||||
|
setBsbPassage(null)
|
||||||
|
setBsbPassageLoading(true)
|
||||||
|
const bookKey = (study?.bibleBook?.trim() || currentStudySlug).toLowerCase()
|
||||||
|
const params = new URLSearchParams({ book: bookKey, reference: section.reference })
|
||||||
|
fetch(`/api/bible-passage?${params}`)
|
||||||
|
.then(r => r.json() as Promise<{ text?: string }>)
|
||||||
|
.then(data => { if (!cancelled && data.text) setBsbPassage(data.text) })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => { if (!cancelled) setBsbPassageLoading(false) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [section?.id, section?.reference, currentStudySlug, study?.bibleBook])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth.checked || !auth.authenticated || !study || !isEnrolled) return
|
if (!auth.checked || !auth.authenticated || !study || !isEnrolled) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -1908,7 +1925,17 @@ export function ColossiansStudySectionPage({ content }: Props) {
|
|||||||
>✎</button>
|
>✎</button>
|
||||||
)}
|
)}
|
||||||
<h2>Scripture Text</h2>
|
<h2>Scripture Text</h2>
|
||||||
<p className="study-detail-copy study-detail-copy--scripture">{section.passageText || `Add the passage text for ${section.reference} here when you move the guide online.`}</p>
|
{bsbPassageLoading
|
||||||
|
? <p className="study-detail-copy" style={{ opacity: 0.5 }}>Loading passage…</p>
|
||||||
|
: (bsbPassage ?? section.passageText)
|
||||||
|
? (bsbPassage ?? section.passageText).split('\n\n').map((para, i) => (
|
||||||
|
<p key={i} className="study-detail-copy study-detail-copy--scripture">
|
||||||
|
{para.split('\n').map((line, j, arr) => j < arr.length - 1 ? <>{line}<br /></> : line)}
|
||||||
|
</p>
|
||||||
|
))
|
||||||
|
: <p className="study-detail-copy" style={{ opacity: 0.5 }}>Passage text not available.</p>
|
||||||
|
}
|
||||||
|
{bsbPassage && <p style={{ fontSize: '0.78rem', color: '#5a5440', marginTop: '0.5rem' }}>Berean Standard Bible (BSB)</p>}
|
||||||
{isEnrolled && openAnchor === 'scripture' && (
|
{isEnrolled && openAnchor === 'scripture' && (
|
||||||
<NoteCard
|
<NoteCard
|
||||||
anchorLabel="Scripture Text"
|
anchorLabel="Scripture Text"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface StudyProgram {
|
|||||||
slug: string
|
slug: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
bibleBook?: string
|
||||||
homepageEyebrow?: string
|
homepageEyebrow?: string
|
||||||
showOnHomepage?: boolean
|
showOnHomepage?: boolean
|
||||||
showNewTag?: boolean
|
showNewTag?: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user