Scripture Index redesign: search, testament/source filters, book chips, collapsible books, on-demand verse text; v2.30.0
Publish Container / build (push) Successful in 15m16s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-12 17:07:28 -04:00
1 parent 9f52437a07
commit 03f46ae683
4 files changed
+250 -90

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "siteforge",
"version": "2.29.0",
"version": "2.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "siteforge",
"version": "2.29.0",
"version": "2.30.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.1135.0",
"@dnd-kit/core": "^6.3.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "2.29.0",
"version": "2.30.0",
"type": "module",
"scripts": {
"dev": "vite",
+76 -22
View File
@@ -12613,28 +12613,20 @@ a.study-track-card:hover {
.kofi-btn:hover { background: var(--brand-gold, #c9a84c); color: var(--brand-black, #0a0a08); }
/* ── Scripture index page ────────────────────────────────────────────────── */
.scripture-index-main {
max-width: 900px;
margin: 0 auto;
padding: 3rem 2rem 5rem;
}
.scripture-index-note { color: var(--brand-muted, #b0a48c); margin: 2rem 0; }
.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; 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.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-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; }
.scripture-entry-ref { grid-column: 1; font-size: 0.9rem; }
.scripture-entry-body { grid-column: 1; }
}
/* ── Admin status messages ───────────────────────────────────────────────── */
.admin-save-ok { color: #6ebd7c; font-size: 0.85rem; margin: 0; }
@@ -13732,3 +13724,65 @@ a.study-track-card:hover {
.sl-actions .btn-secondary { flex: 1 1 100%; justify-content: center; }
}
/* ── Scripture Index ───────────────────────────────────────────────────── */
.si-main { padding: 2rem 0 3.5rem; }
.si-note { color: var(--brand-muted); padding: 1.5rem 0; }
.si-toolbar { display: flex; flex-direction: column; gap: 0.85rem; margin-bottom: 1rem; }
.si-search {
width: 100%; font: inherit; font-size: 1rem; color: var(--brand-warm-white); padding: 0.7rem 1.1rem;
background: var(--brand-black-3); border: 1px solid var(--brand-border); border-radius: 999px;
}
.si-search:focus { outline: 2px solid var(--brand-gold); outline-offset: 1px; }
.si-filters { display: flex; flex-wrap: wrap; gap: 0.6rem 1.5rem; }
.si-pills, .si-books { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.si-books { margin-bottom: 1rem; padding-top: 0.9rem; border-top: 1px solid var(--brand-border); }
.si-pill {
font: inherit; font-size: 0.85rem; cursor: pointer; padding: 0.3rem 0.85rem; border-radius: 999px;
color: var(--brand-muted); background: transparent; border: 1px solid var(--brand-border);
}
.si-pill span { opacity: 0.6; margin-left: 0.2rem; }
.si-pill:hover { border-color: var(--brand-gold-dark); color: var(--brand-warm-white); }
.si-pill--on, .si-pill--on:hover { background: var(--brand-gold); border-color: var(--brand-gold); color: var(--brand-black); }
.si-meta { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.75rem; font-size: 0.88rem; color: var(--brand-muted); }
.si-meta-actions { display: flex; gap: 1rem; }
.si-link { font: inherit; font-size: 0.85rem; background: none; border: 0; padding: 0; cursor: pointer; color: var(--brand-gold); text-decoration: underline; }
.si-groups { display: flex; flex-direction: column; gap: 0.6rem; }
.si-group { background: var(--brand-black-3); border: 1px solid var(--brand-border); border-radius: 12px; overflow: hidden; }
.si-group-head {
display: flex; align-items: baseline; gap: 0.75rem; width: 100%; padding: 0.9rem 1.1rem; background: transparent; border: 0;
cursor: pointer; text-align: left; color: var(--brand-warm-white); font: inherit;
}
.si-group-head:hover { background: rgba(201, 168, 76, 0.06); }
.si-group-name { font-family: var(--brand-font-heading); font-size: 1.5rem; font-weight: 600; line-height: 1.1; }
.si-group-count { font-size: 0.82rem; color: var(--brand-muted); }
.si-chev { margin-left: auto; color: var(--brand-gold); font-size: 1.4rem; line-height: 1; }
.si-list { list-style: none; margin: 0; padding: 0 1.1rem 0.5rem; }
.si-row { padding: 0.7rem 0; border-top: 1px solid var(--brand-border); }
.si-row-top { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.4rem 0.9rem; }
.si-ref { font-weight: 700; color: var(--brand-gold-light); white-space: nowrap; min-width: 8.5rem; }
.si-sources { display: flex; flex-wrap: wrap; gap: 0.4rem; flex: 1; min-width: 0; }
.si-chip {
display: inline-flex; align-items: baseline; gap: 0.4rem; max-width: 100%; padding: 0.15rem 0.65rem; border-radius: 999px;
font-size: 0.85rem; text-decoration: none; color: var(--brand-warm-white); border: 1px solid var(--brand-border);
}
.si-chip:hover { border-color: var(--brand-gold-dark); }
.si-chip-kind { font-size: 0.7rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--brand-gold); flex-shrink: 0; }
.si-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.si-verse-btn { margin-left: auto; font: inherit; font-size: 0.8rem; background: none; border: 0; padding: 0; cursor: pointer; color: var(--brand-muted); text-decoration: underline; }
.si-verse-btn:hover { color: var(--brand-gold); }
.si-verse { margin: 0.5rem 0 0; padding-left: 0.9rem; border-left: 2px solid var(--brand-gold-dark); font-size: 0.95rem; line-height: 1.65; font-style: italic; color: var(--brand-muted); }
@media (max-width: 600px) {
.si-main { padding: 1.25rem 0 2.5rem; }
.si-pills, .si-books { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 0.2rem; }
.si-pill { flex-shrink: 0; }
.si-filters { flex-direction: column; gap: 0.6rem; }
.si-group-name { font-size: 1.3rem; }
.si-list { padding: 0 0.85rem 0.4rem; }
.si-ref { min-width: 0; width: 100%; }
.si-verse-btn { margin-left: 0; }
}
+171 -65
View File
@@ -2350,7 +2350,14 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
)
const [entries, setEntries] = useState<ScriptureIndexEntry[]>([])
const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
const [testament, setTestament] = useState<'all' | 'ot' | 'nt'>('all')
const [sourceFilter, setSourceFilter] = useState<'all' | 'episode' | 'study'>('all')
const [bookFilter, setBookFilter] = useState<string | null>(null)
const [toggled, setToggled] = useState<Record<string, boolean>>({})
const [verseTexts, setVerseTexts] = useState<Record<string, string>>({})
const [shownVerses, setShownVerses] = useState<Set<string>>(new Set())
const chapterCache = useRef<Map<string, Promise<HellaoContent>>>(new Map())
useEffect(() => {
fetch('/api/public/scripture-index')
@@ -2419,88 +2426,187 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) {
return { bookGroups: groups, otherRows: other }
}, [entries])
useEffect(() => {
if (bookGroups.length === 0) return
// Group rows by chapter to minimize API calls
const chapterMap = new Map<string, Array<{ key: string; verseStart: number; verseEnd: number }>>()
for (const group of bookGroups) {
for (const row of group.rows) {
const ck = `${group.bookId}/${row.chapter}`
if (!chapterMap.has(ck)) chapterMap.set(ck, [])
// Chapter-spanning refs (e.g. "5:15-6:2") preview only the starting chapter's
// verses, since extractVerseRange operates on a single fetched chapter.
const verseEnd = row.endChapter !== row.chapter ? Number.MAX_SAFE_INTEGER : row.verseEnd
chapterMap.get(ck)!.push({ key: row.key, verseStart: row.verseStart, verseEnd })
}
}
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<string, string> = {}
for (const r of rows) {
const text = extractVerseRange(chContent, r.verseStart, r.verseEnd)
if (text) updates[r.key] = text
}
setVerseTexts(prev => ({ ...prev, ...updates }))
})
.catch(() => {})
}
}, [bookGroups])
// Apply the search box and filters
const needle = query.trim().toLowerCase()
const rowMatches = (row: ScriptureRow) => {
if (sourceFilter !== 'all' && !row.sources.some(src => src.type === sourceFilter)) return false
if (!needle) return true
return row.displayRef.toLowerCase().includes(needle) || row.sources.some(src => src.label.toLowerCase().includes(needle))
}
const isOldTestament = (bookId: string) => SI_BOOK_ORDER.indexOf(bookId) < 39
const visibleGroups = bookGroups
.filter(g => testament === 'all' || (testament === 'ot') === isOldTestament(g.bookId))
.filter(g => !bookFilter || g.bookId === bookFilter)
.map(g => ({ ...g, rows: g.rows.filter(rowMatches) }))
.filter(g => g.rows.length > 0 || (needle && g.bookName.toLowerCase().includes(needle)))
// A search by book name ("titus") should show the whole book
.map(g => (needle && g.rows.length === 0
? { ...g, rows: bookGroups.find(x => x.bookId === g.bookId)!.rows.filter(r => sourceFilter === 'all' || r.sources.some(src => src.type === sourceFilter)) }
: g))
const visibleOther = testament === 'all' && !bookFilter ? otherRows.filter(rowMatches) : []
const totalRows = visibleGroups.reduce((n, g) => n + g.rows.length, 0) + visibleOther.length
const filtering = Boolean(needle) || testament !== 'all' || sourceFilter !== 'all' || Boolean(bookFilter)
const hasContent = bookGroups.length > 0 || otherRows.length > 0
// With only a few books everything starts open; a long index starts collapsed. Searching opens matches.
const isOpen = (id: string) => toggled[id] ?? (filtering || bookGroups.length <= 4)
const setAll = (open: boolean) => {
const next: Record<string, boolean> = { other: open }
for (const g of bookGroups) next[g.bookId] = open
setToggled(next)
}
const fetchChapter = (bookId: string, chapter: number) => {
const ck = `${bookId}/${chapter}`
let promise = chapterCache.current.get(ck)
if (!promise) {
promise = fetch(`https://bible.helloao.org/api/BSB/${bookId}/${chapter}.json`)
.then(r => (r.ok ? r.json() : Promise.reject(new Error('chapter failed'))))
.then((data: { chapter: { content: HellaoContent } }) => data?.chapter?.content ?? [])
chapterCache.current.set(ck, promise)
}
return promise
}
function toggleVerse(row: ScriptureRow) {
const open = shownVerses.has(row.key)
setShownVerses(prev => {
const next = new Set(prev)
if (open) next.delete(row.key)
else next.add(row.key)
return next
})
if (open || verseTexts[row.key] !== undefined || !row.bookId) return
// Chapter-spanning refs (e.g. "5:15-6:2") preview only the starting chapter's verses
const verseEnd = row.endChapter !== row.chapter ? Number.MAX_SAFE_INTEGER : row.verseEnd
fetchChapter(row.bookId, row.chapter)
.then(chContent => setVerseTexts(prev => ({ ...prev, [row.key]: extractVerseRange(chContent, row.verseStart, verseEnd) || '(Verse text unavailable.)' })))
.catch(() => setVerseTexts(prev => ({ ...prev, [row.key]: '(Verse text unavailable right now.)' })))
}
function renderRow(row: ScriptureRow) {
const showing = shownVerses.has(row.key)
return (
<div key={row.key} className="scripture-entry-row">
<span className="scripture-entry-ref">{row.displayRef}</span>
<span className="scripture-entry-body">
{verseTexts[row.key] && <span className="scripture-entry-verse">"{verseTexts[row.key]}"</span>}
<span className="scripture-entry-sources">
{row.sources.map((s, i) => (
<Link key={i} to={s.url} className={`scripture-source-chip scripture-source-chip--${s.type}`}>
<span className="scripture-source-kind">{s.type === 'episode' ? 'Episode' : 'Study'}</span>
{s.label}
<li key={row.key} className="si-row">
<div className="si-row-top">
<span className="si-ref">{row.displayRef}</span>
<span className="si-sources">
{row.sources.map((src, i) => (
<Link key={i} to={src.url} className={`si-chip si-chip--${src.type}`}>
<span className="si-chip-kind">{src.type === 'episode' ? 'Episode' : 'Study'}</span>
<span className="si-chip-label">{src.label}</span>
</Link>
))}
</span>
</span>
</div>
{row.bookId && (
<button type="button" className="si-verse-btn" aria-expanded={showing} onClick={() => toggleVerse(row)}>
{showing ? 'Hide verse' : 'Show verse'}
</button>
)}
</div>
{showing && (
<p className="si-verse">{verseTexts[row.key] !== undefined ? `“${verseTexts[row.key]}”` : 'Loading…'}</p>
)}
</li>
)
}
const hasContent = bookGroups.length > 0 || otherRows.length > 0
const pill = (active: boolean) => `si-pill${active ? ' si-pill--on' : ''}`
return (
<div className="site">
<SiteHeader content={content} />
<SiteSearchBar content={content} />
<PageBanner eyebrow="Reference" title="Scripture Index" desc="Every passage covered across our studies and episodes, organized by Scripture reference. Find the lesson or episode for any verse." />
<main className="scripture-index-main page-width">
{loading ? (
<p className="scripture-index-note">Loading…</p>
) : !hasContent ? (
<p className="scripture-index-note">No Scripture references are published yet.</p>
) : (
<>
{bookGroups.map(group => (
<section key={group.bookId} className="scripture-study-group">
<h2 className="scripture-study-title">{group.bookName}</h2>
<div className="scripture-entry-table">
{group.rows.map(renderRow)}
<main className="si-main">
<div className="section-inner">
{loading ? (
<p className="si-note">Loading…</p>
) : !hasContent ? (
<p className="si-note">No Scripture references are published yet.</p>
) : (
<>
<div className="si-toolbar">
<input
type="search"
className="si-search"
placeholder="Search a passage or book (e.g. Titus 2)…"
aria-label="Search the Scripture index"
value={query}
onChange={e => setQuery(e.target.value)}
/>
<div className="si-filters">
<div className="si-pills" role="group" aria-label="Testament">
<button type="button" className={pill(testament === 'all')} onClick={() => { setTestament('all'); setBookFilter(null) }}>All</button>
<button type="button" className={pill(testament === 'ot')} onClick={() => { setTestament('ot'); setBookFilter(null) }}>Old Testament</button>
<button type="button" className={pill(testament === 'nt')} onClick={() => { setTestament('nt'); setBookFilter(null) }}>New Testament</button>
</div>
<div className="si-pills" role="group" aria-label="Source">
<button type="button" className={pill(sourceFilter === 'all')} onClick={() => setSourceFilter('all')}>Everything</button>
<button type="button" className={pill(sourceFilter === 'episode')} onClick={() => setSourceFilter('episode')}>Episodes</button>
<button type="button" className={pill(sourceFilter === 'study')} onClick={() => setSourceFilter('study')}>Studies</button>
</div>
</div>
</section>
))}
{otherRows.length > 0 && (
<section className="scripture-study-group">
<h2 className="scripture-study-title">Other References</h2>
<div className="scripture-entry-table">
{otherRows.map(renderRow)}
</div>
{bookGroups.length > 1 && (
<div className="si-books" role="group" aria-label="Books covered">
{bookGroups.map(g => (
<button
key={g.bookId}
type="button"
className={pill(bookFilter === g.bookId)}
onClick={() => setBookFilter(bookFilter === g.bookId ? null : g.bookId)}
>
{g.bookName} <span>{g.rows.length}</span>
</button>
))}
</div>
</section>
)}
</>
)}
)}
<div className="si-meta">
<span>{totalRows} passage{totalRows === 1 ? '' : 's'}{filtering ? ' match' : ''}</span>
<span className="si-meta-actions">
{filtering && (
<button type="button" className="si-link" onClick={() => { setQuery(''); setTestament('all'); setSourceFilter('all'); setBookFilter(null) }}>Clear filters</button>
)}
<button type="button" className="si-link" onClick={() => setAll(true)}>Expand all</button>
<button type="button" className="si-link" onClick={() => setAll(false)}>Collapse all</button>
</span>
</div>
{totalRows === 0 ? (
<p className="si-note">Nothing matches that. Try a book name like “Titus”, or clear the filters.</p>
) : (
<div className="si-groups">
{visibleGroups.map(group => {
const open = isOpen(group.bookId)
return (
<section key={group.bookId} className="si-group">
<button type="button" className="si-group-head" aria-expanded={open} onClick={() => setToggled(t => ({ ...t, [group.bookId]: !open }))}>
<span className="si-group-name">{group.bookName}</span>
<span className="si-group-count">{group.rows.length} passage{group.rows.length === 1 ? '' : 's'}</span>
<span className="si-chev" aria-hidden="true">{open ? '−' : '+'}</span>
</button>
{open && <ul className="si-list">{group.rows.map(renderRow)}</ul>}
</section>
)
})}
{visibleOther.length > 0 && (
<section className="si-group">
<button type="button" className="si-group-head" aria-expanded={isOpen('other')} onClick={() => setToggled(t => ({ ...t, other: !isOpen('other') }))}>
<span className="si-group-name">Other references</span>
<span className="si-group-count">{visibleOther.length} passage{visibleOther.length === 1 ? '' : 's'}</span>
<span className="si-chev" aria-hidden="true">{isOpen('other') ? '−' : '+'}</span>
</button>
{isOpen('other') && <ul className="si-list">{visibleOther.map(renderRow)}</ul>}
</section>
)}
</div>
)}
</>
)}
</div>
</main>
<SiteFooter content={content} />
<AnalyticsConsentBanner content={content} />