Redesign Scripture Index with BSB verse text and row layout; v1.2.1
Publish Container / docker (push) Successful in 13m38s

- Replace cramped auto-fill grid with full-width row layout (reference | verse text | arrow)
- Fetch BSB passage text from bible.helloao.org, grouped by chapter to minimize API calls
- Display 2-line italic excerpt of each passage beneath the lesson title
- Hover state reveals arrow and highlights row in gold
- Mobile-responsive collapse to single column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-03 11:04:22 -04:00
1 parent 83d631608b
commit 36dfa0c1d7
3 files changed
+110 -18

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "1.2.0",
"version": "1.2.1",
"type": "module",
"scripts": {
"dev": "vite",
+17 -8
View File
@@ -12349,14 +12349,23 @@
/* ── Scripture index page ────────────────────────────────────────────────── */
.scripture-index-main { padding: 3rem 1.5rem 5rem; }
.scripture-index-note { color: var(--brand-muted, #b0a48c); margin: 2rem 0; }
.scripture-study-group { margin-bottom: 3rem; }
.scripture-study-title { font-family: var(--brand-font-heading, 'Cormorant Garamond', serif); font-size: 1.6rem; font-weight: 600; color: var(--brand-gold, #c9a84c); margin: 0 0 1.25rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--brand-border, #2a2518); }
.scripture-entry-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.25rem 1.5rem; }
.scripture-entry { margin: 0; }
.scripture-entry-link { display: flex; align-items: baseline; gap: 0.6rem; padding: 0.4rem 0; color: var(--brand-warm-white, #f0ead8); text-decoration: none; border-bottom: 1px solid transparent; transition: color 0.15s, border-color 0.15s; }
.scripture-entry-link:hover { color: var(--brand-gold, #c9a84c); border-bottom-color: var(--brand-gold, #c9a84c); }
.scripture-entry-ref { font-family: var(--brand-font-body, 'EB Garamond', serif); font-size: 0.95rem; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
.scripture-entry-title { font-size: 0.82rem; color: var(--brand-muted, #b0a48c); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.scripture-study-group { margin-bottom: 4rem; }
.scripture-study-title { font-family: var(--brand-font-heading, 'Cormorant Garamond', serif); font-size: 1.6rem; font-weight: 600; color: var(--brand-gold, #c9a84c); margin: 0 0 0; padding-bottom: 0.75rem; border-bottom: 2px solid var(--brand-gold, #c9a84c); }
.scripture-entry-table { display: flex; flex-direction: column; }
.scripture-entry-row { display: grid; grid-template-columns: 130px 1fr 20px; gap: 0 1.5rem; align-items: start; padding: 1rem 0.75rem; border-bottom: 1px solid var(--brand-border, #2a2518); text-decoration: none; color: inherit; transition: background 0.15s; border-radius: 4px; }
.scripture-entry-row:hover { background: rgba(201,168,76,0.06); }
.scripture-entry-ref { font-family: var(--brand-font-body, 'EB Garamond', serif); font-size: 1rem; font-weight: 700; color: var(--brand-gold, #c9a84c); white-space: nowrap; padding-top: 0.1rem; }
.scripture-entry-body { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
.scripture-entry-verse { font-family: var(--brand-font-body, 'EB Garamond', serif); font-size: 0.925rem; line-height: 1.55; color: var(--brand-warm-white, #f0ead8); font-style: italic; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.scripture-entry-lesson { font-size: 0.75rem; letter-spacing: 0.07em; text-transform: uppercase; color: var(--brand-muted, #b0a48c); margin-top: 0.15rem; }
.scripture-entry-arrow { color: var(--brand-gold, #c9a84c); opacity: 0.35; font-size: 1rem; padding-top: 0.1rem; transition: opacity 0.15s, transform 0.15s; }
.scripture-entry-row:hover .scripture-entry-arrow { opacity: 1; transform: translateX(3px); }
@media (max-width: 600px) {
.scripture-entry-row { grid-template-columns: 1fr 20px; }
.scripture-entry-ref { grid-column: 1; font-size: 0.9rem; }
.scripture-entry-body { grid-column: 1; }
.scripture-entry-arrow { grid-column: 2; grid-row: 1 / 3; align-self: center; }
}
/* ── Admin status messages ───────────────────────────────────────────────── */
.admin-save-ok { color: #6ebd7c; font-size: 0.85rem; margin: 0; }
+92 -9
View File
@@ -1939,16 +1939,63 @@ function ResourcesPage({ content }: { content: SiteContent }) {
)
}
const SI_BOOK_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', '1 samuel': 'SA1',
'2 sam': 'SA2', '2 samuel': 'SA2', '1 kgs': 'KI1', '1 kings': 'KI1', '2 kgs': 'KI2', '2 kings': 'KI2',
'1 chr': 'CH1', '1 chronicles': 'CH1', '2 chr': 'CH2', '2 chronicles': 'CH2',
ezra: 'EZR', neh: 'NEH', nehemiah: 'NEH', esth: 'EST', esther: 'EST',
job: 'JOB', ps: 'PSA', psa: 'PSA', psalms: 'PSA', psalm: 'PSA',
prov: 'PRO', proverbs: 'PRO', eccl: 'ECC', ecclesiastes: 'ECC', song: '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 parseSIRef(ref: string) {
const m = ref.match(/^(.*?)\s+(\d+):(\d+)(?:-(\d+))?$/)
if (!m) return null
const bookId = SI_BOOK_MAP[m[1].toLowerCase().replace(/\.\s*/g, ' ').trim()]
if (!bookId) return null
return { bookId, chapter: parseInt(m[2], 10), verseStart: parseInt(m[3], 10), verseEnd: m[4] ? parseInt(m[4], 10) : parseInt(m[3], 10) }
}
type HellaoContent = Array<{ type: string; number?: number; content?: Array<{ text?: string } | string> }>
function extractVerseRange(chContent: HellaoContent, verseStart: number, verseEnd: number): string {
const texts: string[] = []
for (const item of chContent) {
if (item.type === 'verse' && item.number != null && item.number >= verseStart && item.number <= verseEnd) {
const t = (item.content ?? []).map(c => (typeof c === 'string' ? c : ((c as { text?: string }).text ?? ''))).join('').trim()
if (t) texts.push(t)
}
}
return texts.join(' ')
}
function ScriptureIndexPage({ content }: { content: SiteContent }) {
const siteTitle = content.seo?.title || DEFAULTS.seo.title
usePageMeta(
`Scripture Index | ${siteTitle}`,
'Every passage referenced across all Bible studies — find the lesson that covers a particular verse.',
'Every passage covered in our studies, organized by reference. Find the lesson for any verse.',
)
interface ScriptureEntry { sectionId: string; title: string; reference: string }
interface StudyGroup { slug: string; title: string; entries: ScriptureEntry[] }
const [studies, setStudies] = useState<StudyGroup[]>([])
const [loading, setLoading] = useState(true)
const [verseTexts, setVerseTexts] = useState<Record<string, string>>({})
useEffect(() => {
fetch('/api/public/scripture-index')
.then(r => r.json())
@@ -1956,6 +2003,37 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
.catch(() => {})
.finally(() => setLoading(false))
}, [])
useEffect(() => {
if (studies.length === 0) return
// Group entries by chapter to minimize API calls
const chapterMap = new Map<string, Array<{ key: string; verseStart: number; verseEnd: number }>>()
for (const study of studies) {
for (const entry of study.entries) {
const parsed = parseSIRef(entry.reference)
if (!parsed) continue
const ck = `${parsed.bookId}/${parsed.chapter}`
if (!chapterMap.has(ck)) chapterMap.set(ck, [])
chapterMap.get(ck)!.push({ key: `${study.slug}/${entry.sectionId}`, verseStart: parsed.verseStart, verseEnd: parsed.verseEnd })
}
}
for (const [ck, entries] of chapterMap) {
const [bookId, chapter] = ck.split('/')
fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`)
.then(r => r.ok ? r.json() : Promise.reject())
.then((data: { chapter: { content: HellaoContent } }) => {
const chContent = data?.chapter?.content ?? []
const updates: Record<string, string> = {}
for (const e of entries) {
const text = extractVerseRange(chContent, e.verseStart, e.verseEnd)
if (text) updates[e.key] = text
}
setVerseTexts(prev => ({ ...prev, ...updates }))
})
.catch(() => {})
}
}, [studies])
return (
<div className="site">
<SiteHeader content={content} />
@@ -1969,16 +2047,21 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
) : studies.map(study => (
<section key={study.slug} className="scripture-study-group">
<h2 className="scripture-study-title">{study.title}</h2>
<ul className="scripture-entry-list">
{study.entries.map(entry => (
<li key={entry.sectionId} className="scripture-entry">
<Link to={`/study/${study.slug}/${entry.sectionId}`} className="scripture-entry-link">
<div className="scripture-entry-table">
{study.entries.map(entry => {
const verse = verseTexts[`${study.slug}/${entry.sectionId}`]
return (
<Link key={entry.sectionId} to={`/study/${study.slug}/${entry.sectionId}`} className="scripture-entry-row">
<span className="scripture-entry-ref">{entry.reference}</span>
{entry.title && <span className="scripture-entry-title">{entry.title}</span>}
<span className="scripture-entry-body">
{verse && <span className="scripture-entry-verse">"{verse}"</span>}
{entry.title && <span className="scripture-entry-lesson">{entry.title}</span>}
</span>
<span className="scripture-entry-arrow" aria-hidden="true">→</span>
</Link>
</li>
))}
</ul>
)
})}
</div>
</section>
))}
</main>