From 9e722a296d9ee1c990d46188ae4950d4bec33546 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:11:47 -0400 Subject: [PATCH] Redesign the Reader; add ESV New reading surface with paper/sepia/dark themes, slim top and bottom bars, and sheets for navigation, search, settings, audio and per-verse study tools. Plan progress is built into the reader. Page-flip mode removed. ESV added via the server-side proxy (ESV_API_KEY). Co-Authored-By: Claude Sonnet 5.5 --- server/bibleApi.js | 87 +- src/components/StudyPanel.jsx | 4 +- src/components/reader/AudioSheet.jsx | 37 + src/components/reader/BookmarksSheet.jsx | 20 + src/components/reader/NavigatorSheet.jsx | 54 ++ src/components/reader/PlanStrip.jsx | 18 + src/components/reader/ReaderBars.jsx | 51 ++ src/components/reader/ReaderText.jsx | 79 ++ src/components/reader/SearchSheet.jsx | 77 ++ src/components/reader/SettingsSheet.jsx | 43 + src/components/reader/Sheet.jsx | 33 + src/components/reader/VerseSheet.jsx | 103 +++ src/components/reader/icons.jsx | 13 + src/components/reader/readerThemes.js | 18 + src/hooks/useBibleReader.js | 24 + src/pages/ReaderPage.jsx | 981 +++++------------------ src/pages/ReaderPage.test.jsx | 52 ++ 17 files changed, 903 insertions(+), 791 deletions(-) create mode 100644 src/components/reader/AudioSheet.jsx create mode 100644 src/components/reader/BookmarksSheet.jsx create mode 100644 src/components/reader/NavigatorSheet.jsx create mode 100644 src/components/reader/PlanStrip.jsx create mode 100644 src/components/reader/ReaderBars.jsx create mode 100644 src/components/reader/ReaderText.jsx create mode 100644 src/components/reader/SearchSheet.jsx create mode 100644 src/components/reader/SettingsSheet.jsx create mode 100644 src/components/reader/Sheet.jsx create mode 100644 src/components/reader/VerseSheet.jsx create mode 100644 src/components/reader/icons.jsx create mode 100644 src/components/reader/readerThemes.js create mode 100644 src/pages/ReaderPage.test.jsx diff --git a/server/bibleApi.js b/server/bibleApi.js index 635af03..77da414 100644 --- a/server/bibleApi.js +++ b/server/bibleApi.js @@ -19,14 +19,22 @@ if (existsSync(envPath)) { const API_BASE = 'https://rest.api.bible/v1'; +// Canonical book order (index + 1 = the book number the ESV API uses in ranges). +const BOOK_ORDER = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV']; + export const LICENSED_TRANSLATIONS = { - NLT: { bibleId: 'd6e14a625393b4da-01', name: 'New Living Translation' }, - CSB: { bibleId: 'a556c5305ee15c3f-01', name: 'Christian Standard Bible' }, - NASB: { bibleId: 'b8ee27bcd1cae43a-01', name: 'New American Standard Bible (1995)' }, + ESV: { provider: 'esv', name: 'English Standard Version' }, + NLT: { provider: 'api.bible', bibleId: 'd6e14a625393b4da-01', name: 'New Living Translation' }, + CSB: { provider: 'api.bible', bibleId: 'a556c5305ee15c3f-01', name: 'Christian Standard Bible' }, + NASB: { provider: 'api.bible', bibleId: 'b8ee27bcd1cae43a-01', name: 'New American Standard Bible (1995)' }, }; const CACHE_TTL = 24 * 60 * 60 * 1000; const CACHE_MAX = 1000; +// Crossway limits how much ESV text an app may store, so ESV chapters are only +// held briefly and in small numbers (enough to absorb repeat loads). +const ESV_CACHE_TTL = 60 * 60 * 1000; +const ESV_CACHE_MAX = 20; const cache = new Map(); // "NLT/PSA/23" -> { at, payload } // api.bible returns plain text with inline "[12]" verse markers, poetry line @@ -47,7 +55,7 @@ export function parseChapterText(content) { export function registerBibleRoutes(app, requireAuth) { app.get('/api/bible/translations', requireAuth, (_req, res) => { res.json(Object.entries(LICENSED_TRANSLATIONS).map(([id, t]) => ({ - id, name: t.name, available: Boolean(process.env.API_BIBLE_KEY), + id, name: t.name, available: Boolean(t.provider === 'esv' ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY), }))); }); @@ -58,34 +66,57 @@ export function registerBibleRoutes(app, requireAuth) { if (!/^[1-3A-Z]{3}$/i.test(book) || !/^\d{1,3}$/.test(chapter)) { return res.status(400).json({ error: 'Invalid book or chapter.' }); } - const apiKey = process.env.API_BIBLE_KEY; - if (!apiKey) return res.status(503).json({ error: 'API_BIBLE_KEY is not configured on the server.' }); + const id = req.params.translation.toUpperCase(); + const isEsv = translation.provider === 'esv'; + const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY; + if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` }); - const key = `${req.params.translation.toUpperCase()}/${book.toUpperCase()}/${chapter}`; + const key = `${id}/${book.toUpperCase()}/${chapter}`; + const ttl = isEsv ? ESV_CACHE_TTL : CACHE_TTL; const hit = cache.get(key); - if (hit && Date.now() - hit.at < CACHE_TTL) return res.json(hit.payload); + if (hit && Date.now() - hit.at < ttl) return res.json(hit.payload); try { - const qs = new URLSearchParams({ - 'content-type': 'text', 'include-notes': 'false', 'include-titles': 'false', - 'include-chapter-numbers': 'false', 'include-verse-numbers': 'true', 'include-verse-spans': 'false', - }); - const upstream = await fetch( - `${API_BASE}/bibles/${translation.bibleId}/chapters/${book.toUpperCase()}.${chapter}?${qs}`, - { headers: { 'api-key': apiKey } }, - ); - if (upstream.status === 404) return res.status(404).json({ error: 'Chapter not found.' }); - if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` }); - const body = await upstream.json(); - const verses = parseChapterText(body.data?.content); - if (verses.length === 0) return res.status(502).json({ error: 'Bible provider returned no verses.' }); - const payload = { - translation: req.params.translation.toUpperCase(), - verses, - copyright: body.data?.copyright ?? '', - fumsToken: body.meta?.fumsToken ?? null, - }; - if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value); + let payload; + if (isEsv) { + const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1; + if (!bookNum) return res.status(400).json({ error: 'Invalid book.' }); + const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0'); + const qs = new URLSearchParams({ + q: `${prefix}001-${prefix}999`, + 'include-passage-references': 'false', 'include-verse-numbers': 'true', 'include-first-verse-numbers': 'true', + 'include-footnotes': 'false', 'include-headings': 'false', 'include-short-copyright': 'false', + 'include-passage-horizontal-lines': 'false', 'include-heading-horizontal-lines': 'false', + }); + const upstream = await fetch(`https://api.esv.org/v3/passage/text/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } }); + if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' }); + if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` }); + const body = await upstream.json(); + const verses = parseChapterText(body.passages?.[0]); + if (verses.length === 0) return res.status(404).json({ error: 'Chapter not found.' }); + payload = { translation: id, verses, copyright: '', fumsToken: null }; + } else { + const qs = new URLSearchParams({ + 'content-type': 'text', 'include-notes': 'false', 'include-titles': 'false', + 'include-chapter-numbers': 'false', 'include-verse-numbers': 'true', 'include-verse-spans': 'false', + }); + const upstream = await fetch( + `${API_BASE}/bibles/${translation.bibleId}/chapters/${book.toUpperCase()}.${chapter}?${qs}`, + { headers: { 'api-key': apiKey } }, + ); + if (upstream.status === 404) return res.status(404).json({ error: 'Chapter not found.' }); + if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` }); + const body = await upstream.json(); + const verses = parseChapterText(body.data?.content); + if (verses.length === 0) return res.status(502).json({ error: 'Bible provider returned no verses.' }); + payload = { translation: id, verses, copyright: body.data?.copyright ?? '', fumsToken: body.meta?.fumsToken ?? null }; + } + const max = isEsv ? ESV_CACHE_MAX : CACHE_MAX; + if (cache.size >= CACHE_MAX + ESV_CACHE_MAX) cache.delete(cache.keys().next().value); + if (isEsv) { + const esvKeys = [...cache.keys()].filter((k) => k.startsWith('ESV/')); + if (esvKeys.length >= max) cache.delete(esvKeys[0]); + } cache.set(key, { at: Date.now(), payload }); res.json(payload); } catch { diff --git a/src/components/StudyPanel.jsx b/src/components/StudyPanel.jsx index b634d73..39e89c2 100644 --- a/src/components/StudyPanel.jsx +++ b/src/components/StudyPanel.jsx @@ -5,7 +5,7 @@ import { COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync, } from '../hooks/useVerseStudy.js'; -function CommentaryTab({ book, chapter, verse }) { +export function CommentaryTab({ book, chapter, verse }) { const [sourceId, setSourceId] = useState(() => { try { return localStorage.getItem('reader-commentary') || COMMENTARIES[0].id; } catch { return COMMENTARIES[0].id; } }); @@ -77,7 +77,7 @@ function PersonCard({ person }) { ); } -function PeoplePlacesTab({ book, chapter, verse }) { +export function PeoplePlacesTab({ book, chapter, verse }) { const { status, data } = useAsync(() => loadTheographic(book, chapter), [book, chapter]); if (status === 'loading') return

Loading…

; if (status === 'empty') return

No people or places data for this chapter.

; diff --git a/src/components/reader/AudioSheet.jsx b/src/components/reader/AudioSheet.jsx new file mode 100644 index 0000000..ada76d6 --- /dev/null +++ b/src/components/reader/AudioSheet.jsx @@ -0,0 +1,37 @@ +import Sheet from './Sheet.jsx'; +import AudioPlayer from '../AudioPlayer.jsx'; + +// Wraps the existing player. Audio is BSB-only, so licensed translations get a note. +export default function AudioSheet({ open, onClose, translation, bookName, chapter, audio, narrator, onNarrator, onNext, nextDisabled }) { + return ( + + audio.seek(-10)} + onSeekForward={() => audio.seek(10)} + onSeekTo={audio.seekTo} + onNext={onNext} + nextDisabled={nextDisabled} + onStop={audio.stop} + controls={( + + )} + /> + + ); +} diff --git a/src/components/reader/BookmarksSheet.jsx b/src/components/reader/BookmarksSheet.jsx new file mode 100644 index 0000000..ab983cf --- /dev/null +++ b/src/components/reader/BookmarksSheet.jsx @@ -0,0 +1,20 @@ +import Sheet from './Sheet.jsx'; +import { CloseIcon } from './icons.jsx'; + +export default function BookmarksSheet({ open, onClose, entries, onJump, onRemove }) { + return ( + + {entries.length === 0 ? ( +

No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.

+ ) : entries.map((e) => ( +
+ + + +
+ ))} +
+ ); +} diff --git a/src/components/reader/NavigatorSheet.jsx b/src/components/reader/NavigatorSheet.jsx new file mode 100644 index 0000000..0139e4c --- /dev/null +++ b/src/components/reader/NavigatorSheet.jsx @@ -0,0 +1,54 @@ +// One place to pick translation, book and chapter (replaces three dropdowns). +import { useState } from 'react'; +import Sheet from './Sheet.jsx'; +import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx'; +import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js'; + +const NT_START = bookOptions.findIndex((b) => b.abbrev === 'MAT'); +const chip = 'rounded-lg border px-2 py-2 text-center text-sm transition'; + +export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, onGo }) { + const [pickedBook, setPickedBook] = useState(null); // book abbrev whose chapters are showing + const close = () => { setPickedBook(null); onClose(); }; + const book = pickedBook ? bookOptions.find((b) => b.abbrev === pickedBook) : null; + + const bookButton = (b) => ( + + ); + + return ( + setPickedBook(null)} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">‹ All books · {book.name} + ) : 'Go to'}> + {!book && ( + <> +
+ {READER_TRANSLATIONS.map((t) => ( + + ))} +
+

Old Testament

+
{bookOptions.slice(0, NT_START).map(bookButton)}
+

New Testament

+
{bookOptions.slice(NT_START).map(bookButton)}
+ + )} + {book && ( +
+ {Array.from({ length: CHAPTER_COUNTS[book.abbrev] ?? 1 }, (_, i) => i + 1).map((n) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/reader/PlanStrip.jsx b/src/components/reader/PlanStrip.jsx new file mode 100644 index 0000000..a632ce0 --- /dev/null +++ b/src/components/reader/PlanStrip.jsx @@ -0,0 +1,18 @@ +// Thin strip under the top bar while the open chapter belongs to an active +// reading plan. Shows which day you're on and lets you mark it read. +export default function PlanStrip({ matches, notice, onToggle }) { + if (!matches.length && !notice) return null; + return ( +
+ {notice &&

{notice}

} + {matches.map((m) => ( + + ))} +
+ ); +} diff --git a/src/components/reader/ReaderBars.jsx b/src/components/reader/ReaderBars.jsx new file mode 100644 index 0000000..f8c4be0 --- /dev/null +++ b/src/components/reader/ReaderBars.jsx @@ -0,0 +1,51 @@ +// Top and bottom chrome for the reader. Everything stays one tap away; focus +// mode hides both. +import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon } from './icons.jsx'; +import { BookmarkIcon } from '../../utils/bibleUtils.jsx'; + +const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent'; + +export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings }) { + return ( +
+
+ + + + + +
+
+ ); +} + +export function BottomBar({ + onPrev, onNext, prevDisabled, nextDisabled, + onAudio, audioActive, onPen, onCrossRefs, crossRefsOn, onFocus, primary, +}) { + const active = 'text-[var(--r-accent)]'; + return ( +
+
+ + + + + +
+ {primary && ( + + )} + +
+
+ ); +} diff --git a/src/components/reader/ReaderText.jsx b/src/components/reader/ReaderText.jsx new file mode 100644 index 0000000..48fd800 --- /dev/null +++ b/src/components/reader/ReaderText.jsx @@ -0,0 +1,79 @@ +// The page itself: chapter heading, translation notice, and verses set in a +// serif face. Tap a verse to open the verse sheet; select text to highlight. +import { formatCrossRef } from '../../utils/wordStudy.js'; + +export default function ReaderText({ + title, translation, notice, verses, loading, error, layout, fontSize, wide, + bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower, + crossRefs, showCrossRefs, onSelectionEnd, +}) { + const handleTap = (n) => { + const sel = window.getSelection(); + if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping + onSelectVerse(selectedVerse === n ? null : n); + }; + + const verseProps = (v) => { + const bm = bookmarks[`${bookKey}-${chapter}-${v.number}`]; + const selected = selectedVerse === v.number; + return { + id: `reader-verse-${v.number}`, + 'data-verse-num': v.number, + onClick: () => handleTap(v.number), + className: 'cursor-pointer rounded-sm transition-colors', + style: selected + ? { backgroundColor: 'var(--r-hl)' } + : bm ? { backgroundColor: `${bm}66`, boxShadow: `0 0 0 2px ${bm}66` } : undefined, + }; + }; + + const chips = (v) => showCrossRefs && crossRefs?.[v.number]?.length > 0 && ( + + {crossRefs[v.number].slice(0, 4).map((ref, i) => ( + {formatCrossRef(ref)} + ))} + + ); + + const num = (n) => {n}; + + return ( +
+
+

{title}

+

{translation}

+
+ + {loading &&

Loading…

} + {error &&

{error}

} + + {!loading && !error && ( +
+ {layout === 'flow' ? ( +

+ {verses.map((v) => ( + + {num(v.number)}{renderVerseText(v.number, v.text, searchLower)}{chips(v)}{' '} + + ))} +

+ ) : ( + verses.map((v) => ( +

+ {num(v.number)}{renderVerseText(v.number, v.text, searchLower)}{chips(v)} +

+ )) + )} +
+ )} + + {notice && !loading &&

{notice}

} +
+ ); +} diff --git a/src/components/reader/SearchSheet.jsx b/src/components/reader/SearchSheet.jsx new file mode 100644 index 0000000..38c27d1 --- /dev/null +++ b/src/components/reader/SearchSheet.jsx @@ -0,0 +1,77 @@ +// Search this chapter, or the whole Bible (BSB — the only translation that can be +// downloaded once and searched in the browser). +import { useMemo } from 'react'; +import Sheet from './Sheet.jsx'; +import { SearchIcon } from './icons.jsx'; + +export default function SearchSheet({ + open, onClose, query, onQuery, scope, onScope, verses, bookName, chapter, + indexStatus, onLoadIndex, bibleMatches, deferredBibleQuery, onJump, currentBook, +}) { + const q = query.trim().toLowerCase(); + const chapterMatches = useMemo( + () => (scope === 'chapter' && q ? verses.filter((v) => v.text.toLowerCase().includes(q)) : []), + [scope, q, verses], + ); + + const snippet = (text, needle) => { + const i = text.toLowerCase().indexOf(needle); + if (i === -1 || !needle) return text; + return (<>{text.slice(0, i)}{text.slice(i, i + needle.length)}{text.slice(i + needle.length)}); + }; + + const scopeBtn = (id, label, onClick) => ( + + ); + + return ( + +
+ + onQuery(e.target.value)} + placeholder={scope === 'bible' ? 'Search the whole Bible (BSB)…' : `Search ${bookName} ${chapter}…`} + className="min-w-0 flex-1 bg-transparent text-sm text-[var(--r-text)] outline-none placeholder:text-[var(--r-sub)]" /> +
+
+ {scopeBtn('chapter', 'This chapter', () => onScope('chapter'))} + {scopeBtn('bible', 'Whole Bible (BSB)', () => { onScope('bible'); if (indexStatus === 'idle') onLoadIndex(); })} +
+ + {scope === 'chapter' && ( + !q ?

Type a word to find it in this chapter.

+ : chapterMatches.length === 0 ?

No verses match “{query}”.

+ : chapterMatches.map((v) => ( + + )) + )} + + {scope === 'bible' && ( + <> + {indexStatus === 'loading' &&

Loading the full Bible for search. This happens once per visit (about 7 MB).

} + {indexStatus === 'error' &&

Couldn’t load the full Bible.

} + {indexStatus === 'ready' && (!deferredBibleQuery + ?

Type a word to search all 66 books.

+ : bibleMatches.length === 0 + ?

No verses match “{query}”.

+ : ( + <> +

+ {bibleMatches.length} match{bibleMatches.length === 1 ? '' : 'es'}{bibleMatches.length > 100 ? ' (showing first 100)' : ''} +

+ {bibleMatches.slice(0, 100).map((v) => ( + + ))} + + ))} + + )} +
+ ); +} diff --git a/src/components/reader/SettingsSheet.jsx b/src/components/reader/SettingsSheet.jsx new file mode 100644 index 0000000..40affaf --- /dev/null +++ b/src/components/reader/SettingsSheet.jsx @@ -0,0 +1,43 @@ +// "Aa" sheet: text size, theme, and how verses are laid out. +import Sheet from './Sheet.jsx'; +import { READER_THEMES } from './readerThemes.js'; + +const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]]; + +function Segmented({ options, value, onChange }) { + return ( +
+ {options.map(([id, label]) => ( + + ))} +
+ ); +} + +export default function SettingsSheet({ open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide }) { + const row = 'mb-4'; + const label = 'mb-1.5 block text-xs text-[var(--r-sub)]'; + return ( + +
+ Text size + [s, l])} value={fontSize} onChange={onFontSize} /> +
+
+ Theme + [id, t.label])} value={theme} onChange={onTheme} /> +
+
+ Verses + +
+
+ Width + +
+
+ ); +} diff --git a/src/components/reader/Sheet.jsx b/src/components/reader/Sheet.jsx new file mode 100644 index 0000000..806133d --- /dev/null +++ b/src/components/reader/Sheet.jsx @@ -0,0 +1,33 @@ +// Slide-up sheet used for every reader tool. `modal` sheets dim the page and +// close on outside tap; non-modal ones (the verse sheet) leave the text +// tappable. With `pane`, screens >= lg show it as a right-hand side pane +// instead of a bottom sheet (tablet landscape / desktop). +import { CloseIcon } from './icons.jsx'; + +export default function Sheet({ open, onClose, title, headerRight, modal = true, pane = false, children }) { + if (!open) return null; + return ( + <> + {modal &&