From 03f46ae683da6cc5cb7697181fb6a17945d617b8 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 12 Oct 2026 17:07:28 -0400 Subject: [PATCH] Scripture Index redesign: search, testament/source filters, book chips, collapsible books, on-demand verse text; v2.30.0 Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 4 +- package.json | 2 +- src/App.css | 98 ++++++++++++++----- src/App.tsx | 236 +++++++++++++++++++++++++++++++++------------- 4 files changed, 250 insertions(+), 90 deletions(-) diff --git a/package-lock.json b/package-lock.json index 788d5df..a5df2d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3f4be32..2783464 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.29.0", + "version": "2.30.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.css b/src/App.css index c5d8c2b..bd14aa4 100644 --- a/src/App.css +++ b/src/App.css @@ -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; } +} + diff --git a/src/App.tsx b/src/App.tsx index 4db25c4..5ad253c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2350,7 +2350,14 @@ function ScriptureIndexPage({ content }: { content: SiteContent }) { ) const [entries, setEntries] = useState([]) 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(null) + const [toggled, setToggled] = useState>({}) const [verseTexts, setVerseTexts] = useState>({}) + const [shownVerses, setShownVerses] = useState>(new Set()) + const chapterCache = useRef>>(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>() - 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 = {} - 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 = { 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 ( -
- {row.displayRef} - - {verseTexts[row.key] && "{verseTexts[row.key]}"} - - {row.sources.map((s, i) => ( - - {s.type === 'episode' ? 'Episode' : 'Study'} - {s.label} +
  • +
    + {row.displayRef} + + {row.sources.map((src, i) => ( + + {src.type === 'episode' ? 'Episode' : 'Study'} + {src.label} ))} - -
    + {row.bookId && ( + + )} +
  • + {showing && ( +

    {verseTexts[row.key] !== undefined ? `“${verseTexts[row.key]}”` : 'Loading…'}

    + )} + ) } + const hasContent = bookGroups.length > 0 || otherRows.length > 0 + const pill = (active: boolean) => `si-pill${active ? ' si-pill--on' : ''}` + return (
    -
    - {loading ? ( -

    Loading…

    - ) : !hasContent ? ( -

    No Scripture references are published yet.

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

    {group.bookName}

    -
    - {group.rows.map(renderRow)} +
    +
    + {loading ? ( +

    Loading…

    + ) : !hasContent ? ( +

    No Scripture references are published yet.

    + ) : ( + <> +
    + setQuery(e.target.value)} + /> +
    +
    + + + +
    +
    + + + +
    -
    - ))} - {otherRows.length > 0 && ( -
    -

    Other References

    -
    - {otherRows.map(renderRow)} +
    + + {bookGroups.length > 1 && ( +
    + {bookGroups.map(g => ( + + ))}
    -
    - )} - - )} + )} + +
    + {totalRows} passage{totalRows === 1 ? '' : 's'}{filtering ? ' match' : ''} + + {filtering && ( + + )} + + + +
    + + {totalRows === 0 ? ( +

    Nothing matches that. Try a book name like “Titus”, or clear the filters.

    + ) : ( +
    + {visibleGroups.map(group => { + const open = isOpen(group.bookId) + return ( +
    + + {open &&
      {group.rows.map(renderRow)}
    } +
    + ) + })} + {visibleOther.length > 0 && ( +
    + + {isOpen('other') &&
      {visibleOther.map(renderRow)}
    } +
    + )} +
    + )} + + )} +