Redesign Scripture Index with BSB verse text and row layout; v1.2.1
Publish Container / docker (push) Successful in 13m38s
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:
1 parent
83d631608b
commit
36dfa0c1d7
3 files changed
+110
-18
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+17
-8
@@ -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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user