diff --git a/server/routes/public.js b/server/routes/public.js index bed6cfa..6e547dd 100644 --- a/server/routes/public.js +++ b/server/routes/public.js @@ -159,23 +159,44 @@ export function register(app) { `) }) - // Scripture index — all released study lessons with their passage references + // Scripture index — every passage covered across released study lessons and podcast episodes app.get('/api/public/scripture-index', (_req, res) => { const content = state.cachedSiteContent const allStudies = Array.isArray(content?.studies) ? content.studies : [] + const allEpisodes = Array.isArray(content?.podcastFeaturedLinks) ? content.podcastFeaturedLinks : [] const cutoff = new Date() - const studies = allStudies - .filter(s => s.slug && Array.isArray(s.sections)) - .map(s => ({ - slug: s.slug, - title: s.title || s.slug, - entries: s.sections - .filter(sec => sec.id && sec.reference && sec.releasedAt && new Date(sec.releasedAt) <= cutoff) - .map(sec => ({ sectionId: sec.id, title: sec.title || '', reference: sec.reference })) - .sort((a, b) => a.reference.localeCompare(b.reference, undefined, { numeric: true })), - })) - .filter(s => s.entries.length > 0) - res.json({ studies }) + const entries = [] + + for (const s of allStudies) { + if (!s.slug || !Array.isArray(s.sections)) continue + const bookName = s.bibleBook || s.slug + for (const sec of s.sections) { + if (!sec.id || !sec.reference || !sec.releasedAt) continue + if (new Date(sec.releasedAt) > cutoff) continue + entries.push({ + reference: `${bookName} ${sec.reference}`, + sourceType: 'study', + sourceTitle: s.title || s.slug, + sectionTitle: sec.title || '', + url: `/study/${s.slug}/${sec.id}`, + }) + } + } + + for (const ep of allEpisodes) { + if (!ep.id || !Array.isArray(ep.showNotesScripture)) continue + for (const ref of ep.showNotesScripture) { + if (!ref || !ref.trim()) continue + entries.push({ + reference: ref.trim(), + sourceType: 'episode', + sourceTitle: ep.episodeNumber ? `Episode ${ep.episodeNumber}: ${ep.title}` : (ep.title || 'Episode'), + url: `/episodes/${ep.id}`, + }) + } + } + + res.json({ entries }) }) // SPA static files and fallback diff --git a/src/App.css b/src/App.css index 5d3f188..0193bba 100644 --- a/src/App.css +++ b/src/App.css @@ -12531,19 +12531,18 @@ .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-row { display: grid; grid-template-columns: 130px 1fr; gap: 0 1.5rem; align-items: start; padding: 1rem 0.75rem; border-bottom: 1px solid var(--brand-border, #2a2518); border-radius: 4px; } .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-body { display: flex; flex-direction: column; gap: 0.4rem; 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); } +.scripture-entry-sources { display: flex; flex-wrap: wrap; gap: 0.5rem; } +.scripture-source-chip { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; color: var(--brand-warm-white, #f0ead8); text-decoration: none; padding: 0.3rem 0.6rem; border: 1px solid var(--brand-border, #2a2518); border-radius: 999px; transition: background 0.15s, border-color 0.15s; } +.scripture-source-chip:hover { background: rgba(201,168,76,0.1); border-color: var(--brand-gold, #c9a84c); } +.scripture-source-kind { font-size: 0.65rem; letter-spacing: 0.07em; text-transform: uppercase; color: var(--brand-gold, #c9a84c); } @media (max-width: 600px) { - .scripture-entry-row { grid-template-columns: 1fr 20px; } + .scripture-entry-row { grid-template-columns: 1fr; } .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 ───────────────────────────────────────────────── */ diff --git a/src/App.tsx b/src/App.tsx index 7904cca..b1d29e0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useMemo } from 'react' import type { ReactElement } from 'react' import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom' import AdminPage from './AdminPage' @@ -1979,28 +1979,83 @@ 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', +const BIBLE_BOOKS: Array<{ id: string; name: string; aliases?: string[] }> = [ + { id: 'GEN', name: 'Genesis', aliases: ['gen'] }, + { id: 'EXO', name: 'Exodus', aliases: ['exo'] }, + { id: 'LEV', name: 'Leviticus', aliases: ['lev'] }, + { id: 'NUM', name: 'Numbers', aliases: ['num'] }, + { id: 'DEU', name: 'Deuteronomy', aliases: ['deut'] }, + { id: 'JOS', name: 'Joshua', aliases: ['josh'] }, + { id: 'JDG', name: 'Judges', aliases: ['judg'] }, + { id: 'RUT', name: 'Ruth' }, + { id: 'SA1', name: '1 Samuel', aliases: ['1 sam'] }, + { id: 'SA2', name: '2 Samuel', aliases: ['2 sam'] }, + { id: 'KI1', name: '1 Kings', aliases: ['1 kgs'] }, + { id: 'KI2', name: '2 Kings', aliases: ['2 kgs'] }, + { id: 'CH1', name: '1 Chronicles', aliases: ['1 chr'] }, + { id: 'CH2', name: '2 Chronicles', aliases: ['2 chr'] }, + { id: 'EZR', name: 'Ezra' }, + { id: 'NEH', name: 'Nehemiah', aliases: ['neh'] }, + { id: 'EST', name: 'Esther', aliases: ['esth'] }, + { id: 'JOB', name: 'Job' }, + { id: 'PSA', name: 'Psalms', aliases: ['ps', 'psa', 'psalm'] }, + { id: 'PRO', name: 'Proverbs', aliases: ['prov'] }, + { id: 'ECC', name: 'Ecclesiastes', aliases: ['eccl'] }, + { id: 'SNG', name: 'Song of Solomon', aliases: ['song'] }, + { id: 'ISA', name: 'Isaiah', aliases: ['isa'] }, + { id: 'JER', name: 'Jeremiah', aliases: ['jer'] }, + { id: 'LAM', name: 'Lamentations', aliases: ['lam'] }, + { id: 'EZK', name: 'Ezekiel', aliases: ['ezek'] }, + { id: 'DAN', name: 'Daniel', aliases: ['dan'] }, + { id: 'HOS', name: 'Hosea', aliases: ['hos'] }, + { id: 'JOL', name: 'Joel' }, + { id: 'AMO', name: 'Amos' }, + { id: 'OBA', name: 'Obadiah', aliases: ['obad'] }, + { id: 'JNA', name: 'Jonah' }, + { id: 'MIC', name: 'Micah', aliases: ['mic'] }, + { id: 'NAH', name: 'Nahum', aliases: ['nah'] }, + { id: 'HAB', name: 'Habakkuk', aliases: ['hab'] }, + { id: 'ZEP', name: 'Zephaniah', aliases: ['zeph'] }, + { id: 'HAG', name: 'Haggai', aliases: ['hag'] }, + { id: 'ZEC', name: 'Zechariah', aliases: ['zech'] }, + { id: 'MAL', name: 'Malachi', aliases: ['mal'] }, + { id: 'MAT', name: 'Matthew', aliases: ['matt'] }, + { id: 'MRK', name: 'Mark' }, + { id: 'LUK', name: 'Luke' }, + { id: 'JHN', name: 'John' }, + { id: 'ACT', name: 'Acts' }, + { id: 'ROM', name: 'Romans', aliases: ['rom'] }, + { id: 'CO1', name: '1 Corinthians', aliases: ['1 cor'] }, + { id: 'CO2', name: '2 Corinthians', aliases: ['2 cor'] }, + { id: 'GAL', name: 'Galatians', aliases: ['gal'] }, + { id: 'EPH', name: 'Ephesians', aliases: ['eph'] }, + { id: 'PHP', name: 'Philippians', aliases: ['phil'] }, + { id: 'COL', name: 'Colossians', aliases: ['col'] }, + { id: 'TH1', name: '1 Thessalonians', aliases: ['1 thess'] }, + { id: 'TH2', name: '2 Thessalonians', aliases: ['2 thess'] }, + { id: 'TI1', name: '1 Timothy', aliases: ['1 tim'] }, + { id: 'TI2', name: '2 Timothy', aliases: ['2 tim'] }, + { id: 'TIT', name: 'Titus' }, + { id: 'PHM', name: 'Philemon', aliases: ['philem'] }, + { id: 'HEB', name: 'Hebrews', aliases: ['heb'] }, + { id: 'JAM', name: 'James', aliases: ['jas'] }, + { id: 'PE1', name: '1 Peter', aliases: ['1 pet'] }, + { id: 'PE2', name: '2 Peter', aliases: ['2 pet'] }, + { id: 'JO1', name: '1 John' }, + { id: 'JO2', name: '2 John' }, + { id: 'JO3', name: '3 John' }, + { id: 'JDE', name: 'Jude', aliases: ['jude'] }, + { id: 'REV', name: 'Revelation', aliases: ['rev'] }, +] + +const SI_BOOK_MAP: Record = {} +const SI_BOOK_NAME: Record = {} +const SI_BOOK_ORDER: string[] = [] +for (const b of BIBLE_BOOKS) { + SI_BOOK_MAP[b.name.toLowerCase()] = b.id + for (const alias of b.aliases ?? []) SI_BOOK_MAP[alias] = b.id + SI_BOOK_NAME[b.id] = b.name + SI_BOOK_ORDER.push(b.id) } function parseSIRef(ref: string) { @@ -2024,86 +2079,180 @@ function extractVerseRange(chContent: HellaoContent, verseStart: number, verseEn return texts.join(' ') } +interface ScriptureIndexEntry { + reference: string + sourceType: 'study' | 'episode' + sourceTitle: string + sectionTitle?: string + url: string +} + +interface ScriptureSource { type: 'study' | 'episode'; label: string; url: string } + +interface ScriptureRow { + key: string + displayRef: string + bookId: string | null + chapter: number + verseStart: number + verseEnd: number + sources: ScriptureSource[] +} + +interface ScriptureBookGroup { bookId: string; bookName: string; rows: ScriptureRow[] } + function ScriptureIndexPage({ content }: { content: SiteContent }) { const siteTitle = content.seo?.title || DEFAULTS.seo.title usePageMeta( `Scripture Index | ${siteTitle}`, - 'Every passage covered in our studies, organized by reference. Find the lesson for any verse.', + 'Every passage covered across our studies and episodes, organized by Scripture reference. Find the lesson or episode for any verse.', ) - interface ScriptureEntry { sectionId: string; title: string; reference: string } - interface StudyGroup { slug: string; title: string; entries: ScriptureEntry[] } - const [studies, setStudies] = useState([]) + const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) const [verseTexts, setVerseTexts] = useState>({}) useEffect(() => { fetch('/api/public/scripture-index') .then(r => r.json()) - .then(d => { if (Array.isArray(d.studies)) setStudies(d.studies) }) + .then(d => { if (Array.isArray(d.entries)) setEntries(d.entries) }) .catch(() => {}) .finally(() => setLoading(false)) }, []) + // Merge entries that share the same reference (a study lesson and an episode can both + // cover the same passage) into one row, then group rows by book in canonical Bible order. + const { bookGroups, otherRows } = useMemo(() => { + const rowMap = new Map() + const order: string[] = [] + for (const entry of entries) { + const parsed = parseSIRef(entry.reference) + const source: ScriptureSource = { + type: entry.sourceType, + url: entry.url, + label: entry.sourceType === 'study' + ? `${entry.sourceTitle}${entry.sectionTitle ? ` — ${entry.sectionTitle}` : ''}` + : entry.sourceTitle, + } + const key = parsed + ? `${parsed.bookId}-${parsed.chapter}-${parsed.verseStart}-${parsed.verseEnd}` + : `other-${entry.reference.trim().toLowerCase()}` + let row = rowMap.get(key) + if (!row) { + row = { + key, + displayRef: parsed + ? `${SI_BOOK_NAME[parsed.bookId]} ${parsed.chapter}:${parsed.verseStart}${parsed.verseEnd !== parsed.verseStart ? `-${parsed.verseEnd}` : ''}` + : entry.reference.trim(), + bookId: parsed?.bookId ?? null, + chapter: parsed?.chapter ?? 0, + verseStart: parsed?.verseStart ?? 0, + verseEnd: parsed?.verseEnd ?? 0, + sources: [], + } + rowMap.set(key, row) + order.push(key) + } + if (!row.sources.some(s => s.url === source.url && s.label === source.label)) row.sources.push(source) + } + + const groupsByBook = new Map() + const other: ScriptureRow[] = [] + for (const k of order) { + const row = rowMap.get(k)! + if (!row.bookId) { other.push(row); continue } + if (!groupsByBook.has(row.bookId)) groupsByBook.set(row.bookId, []) + groupsByBook.get(row.bookId)!.push(row) + } + const groups: ScriptureBookGroup[] = SI_BOOK_ORDER + .filter(id => groupsByBook.has(id)) + .map(id => ({ + bookId: id, + bookName: SI_BOOK_NAME[id], + rows: groupsByBook.get(id)!.sort((a, b) => a.chapter - b.chapter || a.verseStart - b.verseStart), + })) + other.sort((a, b) => a.displayRef.localeCompare(b.displayRef)) + return { bookGroups: groups, otherRows: other } + }, [entries]) + useEffect(() => { - if (studies.length === 0) return - // Group entries by chapter to minimize API calls + if (bookGroups.length === 0) return + // Group rows 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}` + for (const group of bookGroups) { + for (const row of group.rows) { + const ck = `${group.bookId}/${row.chapter}` if (!chapterMap.has(ck)) chapterMap.set(ck, []) - chapterMap.get(ck)!.push({ key: `${study.slug}/${entry.sectionId}`, verseStart: parsed.verseStart, verseEnd: parsed.verseEnd }) + chapterMap.get(ck)!.push({ key: row.key, verseStart: row.verseStart, verseEnd: row.verseEnd }) } } - for (const [ck, entries] of chapterMap) { + for (const [ck, rows] 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 + for (const r of rows) { + const text = extractVerseRange(chContent, r.verseStart, r.verseEnd) + if (text) updates[r.key] = text } setVerseTexts(prev => ({ ...prev, ...updates })) }) .catch(() => {}) } - }, [studies]) + }, [bookGroups]) + + const hasContent = bookGroups.length > 0 || otherRows.length > 0 + + function renderRow(row: ScriptureRow) { + return ( +
+ {row.displayRef} + + {verseTexts[row.key] && "{verseTexts[row.key]}"} + + {row.sources.map((s, i) => ( + + {s.type === 'episode' ? 'Episode' : 'Study'} + {s.label} + + ))} + + +
+ ) + } return (
- +
{loading ? (

Loading…

- ) : studies.length === 0 ? ( -

No study lessons are published yet.

- ) : studies.map(study => ( -
-

{study.title}

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

No Scripture references are published yet.

+ ) : ( + <> + {bookGroups.map(group => ( +
+

{group.bookName}

+
+ {group.rows.map(renderRow)} +
+
+ ))} + {otherRows.length > 0 && ( +
+

Other References

+
+ {otherRows.map(renderRow)} +
+
+ )} + + )}