diff --git a/package.json b/package.json index b17cabf..c8c36d5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "1.2.0", + "version": "1.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.css b/src/App.css index 8a8ff4a..6031da4 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } diff --git a/src/App.tsx b/src/App.tsx index 86fe447..7c32a91 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1939,16 +1939,63 @@ function ResourcesPage({ content }: { content: SiteContent }) { ) } +const SI_BOOK_MAP: Record = { + 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([]) const [loading, setLoading] = useState(true) + const [verseTexts, setVerseTexts] = useState>({}) + 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>() + 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 = {} + 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 (
@@ -1969,16 +2047,21 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) { ) : studies.map(study => (

{study.title}

-
    - {study.entries.map(entry => ( -
  • - +
    + {study.entries.map(entry => { + const verse = verseTexts[`${study.slug}/${entry.sectionId}`] + return ( + {entry.reference} - {entry.title && {entry.title}} + + {verse && "{verse}"} + {entry.title && {entry.title}} + + -
  • - ))} -
+ ) + })} +
))}