Redesign the Reader; add ESV
Build and Push Docker Image / build-and-push (push) Failing after 20s

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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 09:11:47 -04:00
1 parent 9e8420a1ae
commit 9e722a296d
17 files changed
+887 -775

No files matched your search

+46 -15
View File
@@ -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,14 +66,36 @@ 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 {
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',
@@ -79,13 +109,14 @@ export function registerBibleRoutes(app, requireAuth) {
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);
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 {
+2 -2
View File
@@ -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 <p className="text-xs text-slate-500">Loading…</p>;
if (status === 'empty') return <p className="text-xs text-slate-500">No people or places data for this chapter.</p>;
+37
View File
@@ -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 (
<Sheet open={open} onClose={onClose} title="Listen">
<AudioPlayer
title={`${bookName} ${chapter}`}
subtitle={audio.state.status === 'error'
? "Couldn't load audio for this chapter/narrator."
: translation !== 'BSB'
? `Audio is BSB only — no ${translation} audio is available, so it won't match the ${translation} text on screen.`
: 'Audio follows the chapter as you browse.'}
status={audio.state.status}
currentTime={audio.progress.currentTime}
duration={audio.progress.duration}
onPlay={audio.play}
onTogglePause={audio.togglePause}
onSeekBack={() => audio.seek(-10)}
onSeekForward={() => audio.seek(10)}
onSeekTo={audio.seekTo}
onNext={onNext}
nextDisabled={nextDisabled}
onStop={audio.stop}
controls={(
<select value={narrator} onChange={(e) => onNarrator(e.target.value)}
className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
</select>
)}
/>
</Sheet>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx';
export default function BookmarksSheet({ open, onClose, entries, onJump, onRemove }) {
return (
<Sheet open={open} onClose={onClose} title={`Bookmarks${entries.length ? ` (${entries.length})` : ''}`}>
{entries.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
) : entries.map((e) => (
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse}
</button>
<button type="button" onClick={() => onRemove(e.key)} aria-label="Remove bookmark" className="rounded p-1 text-[var(--r-sub)] hover:text-red-600"><CloseIcon /></button>
</div>
))}
</Sheet>
);
}
+54
View File
@@ -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) => (
<button key={b.abbrev} type="button" onClick={() => setPickedBook(b.abbrev)}
className={`${chip} truncate ${b.abbrev === bookAbbrev ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
{b.name}
</button>
);
return (
<Sheet open={open} onClose={close} title={book ? (
<button type="button" onClick={() => setPickedBook(null)} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">‹ All books · <span className="text-[var(--r-text)]">{book.name}</span></button>
) : 'Go to'}>
{!book && (
<>
<div className="mb-3 flex flex-wrap gap-1.5">
{READER_TRANSLATIONS.map((t) => (
<button key={t.id} type="button" onClick={() => onTranslation(t.id)} title={t.name}
className={`rounded-full border px-3 py-1 text-xs font-medium ${t.id === translation ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{t.id}
</button>
))}
</div>
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Old Testament</p>
<div className="mb-4 grid grid-cols-3 gap-1.5 sm:grid-cols-4">{bookOptions.slice(0, NT_START).map(bookButton)}</div>
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">New Testament</p>
<div className="grid grid-cols-3 gap-1.5 sm:grid-cols-4">{bookOptions.slice(NT_START).map(bookButton)}</div>
</>
)}
{book && (
<div className="grid grid-cols-6 gap-1.5 sm:grid-cols-8">
{Array.from({ length: CHAPTER_COUNTS[book.abbrev] ?? 1 }, (_, i) => i + 1).map((n) => (
<button key={n} type="button" onClick={() => { onGo(book.abbrev, n); close(); }}
className={`${chip} ${book.abbrev === bookAbbrev && n === chapter ? 'border-[var(--r-accent)] bg-[var(--r-accent)] text-[var(--r-on)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
{n}
</button>
))}
</div>
)}
</Sheet>
);
}
+18
View File
@@ -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 (
<div className="mx-auto flex max-w-2xl flex-col gap-1 px-4 pt-2 font-sans">
{notice && <p className="rounded-lg bg-[var(--r-bar)] px-3 py-1.5 text-xs text-[var(--r-accent)]">{notice}</p>}
{matches.map((m) => (
<button key={m.progressId} type="button" onClick={() => onToggle(m)}
className="flex items-center justify-between gap-3 rounded-lg bg-[var(--r-bar)] px-3 py-1.5 text-left text-xs text-[var(--r-sub)] hover:bg-black/5"
title={m.done ? 'Tap to unmark this day' : 'Tap to mark this day read'}>
<span className="truncate"><span className="text-[var(--r-accent)]">{m.done ? '✓' : '●'}</span> {m.plan.title} · Day {m.dayIndex + 1} of {m.plan.days}</span>
<span className="shrink-0">{m.total > 1 ? `${m.position} of ${m.total}` : m.done ? 'Read' : 'Mark read'}</span>
</button>
))}
</div>
);
}
+51
View File
@@ -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 (
<div className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
<button type="button" onClick={onBack} className={iconBtn} aria-label="Back to studies"><ChevronLeft /></button>
<button type="button" onClick={onOpenNavigator}
className="mx-auto flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--r-text)] hover:bg-black/5">
<span className="truncate">{title}</span><ChevronDown />
</button>
<button type="button" onClick={onSearch} className={iconBtn} aria-label="Search"><SearchIcon /></button>
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks">
<BookmarkIcon filled={bookmarkCount > 0} />
</button>
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button>
</div>
</div>
);
}
export function BottomBar({
onPrev, onNext, prevDisabled, nextDisabled,
onAudio, audioActive, onPen, onCrossRefs, crossRefsOn, onFocus, primary,
}) {
const active = 'text-[var(--r-accent)]';
return (
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-[var(--r-line)] bg-[var(--r-bar)] pb-[env(safe-area-inset-bottom)] font-sans">
<div className="mx-auto flex h-14 max-w-4xl items-center gap-0.5 px-2">
<button type="button" onClick={onPrev} disabled={prevDisabled} className={iconBtn} aria-label="Previous chapter"><ChevronLeft /></button>
<button type="button" onClick={onAudio} className={`${iconBtn} ${audioActive ? active : ''}`} aria-label="Listen"><HeadphonesIcon /></button>
<button type="button" onClick={onPen} className={iconBtn} aria-label="Draw notes"><PenIcon /></button>
<button type="button" onClick={onCrossRefs} className={`${iconBtn} ${crossRefsOn ? active : ''}`} aria-label="Show cross-references under verses"><LinkIcon /></button>
<button type="button" onClick={onFocus} className={iconBtn} aria-label="Hide controls"><FocusIcon /></button>
<div className="mx-auto" />
{primary && (
<button type="button" onClick={primary.onClick}
className="mr-1 flex items-center gap-1.5 rounded-full bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">
{primary.done ? null : <CheckIcon />}{primary.label}
</button>
)}
<button type="button" onClick={onNext} disabled={nextDisabled} className={iconBtn} aria-label="Next chapter"><ChevronRight /></button>
</div>
</div>
);
}
+79
View File
@@ -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 && (
<span className="ml-1 inline-flex flex-wrap gap-1 align-baseline font-sans">
{crossRefs[v.number].slice(0, 4).map((ref, i) => (
<span key={i} className="rounded-full border border-[var(--r-line)] px-1.5 py-px text-[10px] text-[var(--r-sub)]">{formatCrossRef(ref)}</span>
))}
</span>
);
const num = (n) => <sup className="mr-1 select-none font-sans text-[0.6em] text-[var(--r-sub)]">{n}</sup>;
return (
<article
className={`mx-auto px-5 pb-32 pt-6 text-[var(--r-text)] ${wide ? 'max-w-5xl' : 'max-w-2xl'}`}
onPointerUp={onSelectionEnd}
>
<header className="mb-5 text-center">
<h1 className="font-serif text-3xl font-normal tracking-tight">{title}</h1>
<p className="mt-1 font-sans text-xs text-[var(--r-sub)]">{translation}</p>
</header>
{loading && <p className="text-center font-sans text-sm text-[var(--r-sub)]">Loading…</p>}
{error && <p className="text-center font-sans text-sm text-red-600">{error}</p>}
{!loading && !error && (
<div
className={`font-serif ${wide ? 'md:columns-2 md:gap-x-12' : ''}`}
style={{ fontSize: `${1.125 * fontSize}rem`, lineHeight: 1.85 }}
>
{layout === 'flow' ? (
<p className="text-pretty">
{verses.map((v) => (
<span key={v.number} {...verseProps(v)}>
{num(v.number)}{renderVerseText(v.number, v.text, searchLower)}{chips(v)}{' '}
</span>
))}
</p>
) : (
verses.map((v) => (
<p key={v.number} className="mb-3 break-inside-avoid">
<span {...verseProps(v)}>{num(v.number)}{renderVerseText(v.number, v.text, searchLower)}{chips(v)}</span>
</p>
))
)}
</div>
)}
{notice && !loading && <p className="mt-10 border-t border-[var(--r-line)] pt-3 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{notice}</p>}
</article>
);
}
+77
View File
@@ -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)}<mark className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{text.slice(i, i + needle.length)}</mark>{text.slice(i + needle.length)}</>);
};
const scopeBtn = (id, label, onClick) => (
<button type="button" onClick={onClick}
className={`px-3 py-1.5 text-xs ${scope === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{label}</button>
);
return (
<Sheet open={open} onClose={onClose} title="Search">
<div className="mb-3 flex items-center gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2">
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
<input autoFocus value={query} onChange={(e) => 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)]" />
</div>
<div className="mb-3 inline-flex overflow-hidden rounded-lg border border-[var(--r-line)]">
{scopeBtn('chapter', 'This chapter', () => onScope('chapter'))}
{scopeBtn('bible', 'Whole Bible (BSB)', () => { onScope('bible'); if (indexStatus === 'idle') onLoadIndex(); })}
</div>
{scope === 'chapter' && (
!q ? <p className="text-sm text-[var(--r-sub)]">Type a word to find it in this chapter.</p>
: chapterMatches.length === 0 ? <p className="text-sm text-[var(--r-sub)]">No verses match “{query}”.</p>
: chapterMatches.map((v) => (
<button key={v.number} type="button" onClick={() => onJump(currentBook, chapter, v.number)}
className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
<span className="mr-1.5 font-medium">{v.number}</span>{snippet(v.text, q)}
</button>
))
)}
{scope === 'bible' && (
<>
{indexStatus === 'loading' && <p className="text-sm text-[var(--r-sub)]">Loading the full Bible for search. This happens once per visit (about 7 MB).</p>}
{indexStatus === 'error' && <p className="text-sm text-red-600">Couldn’t load the full Bible. <button type="button" onClick={onLoadIndex} className="underline">Try again</button></p>}
{indexStatus === 'ready' && (!deferredBibleQuery
? <p className="text-sm text-[var(--r-sub)]">Type a word to search all 66 books.</p>
: bibleMatches.length === 0
? <p className="text-sm text-[var(--r-sub)]">No verses match “{query}”.</p>
: (
<>
<p className="mb-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
{bibleMatches.length} match{bibleMatches.length === 1 ? '' : 'es'}{bibleMatches.length > 100 ? ' (showing first 100)' : ''}
</p>
{bibleMatches.slice(0, 100).map((v) => (
<button key={`${v.bookAbbrev}-${v.chapter}-${v.verse}`} type="button" onClick={() => onJump(v.bookAbbrev, v.chapter, v.verse)}
className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
<span className="mr-1.5 font-medium">{v.bookName} {v.chapter}:{v.verse}</span>{snippet(v.text, deferredBibleQuery)}
</button>
))}
</>
))}
</>
)}
</Sheet>
);
}
+43
View File
@@ -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 (
<div className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{options.map(([id, label]) => (
<button key={id} type="button" onClick={() => onChange(id)}
className={`flex-1 px-3 py-2 text-sm ${value === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
{label}
</button>
))}
</div>
);
}
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 (
<Sheet open={open} onClose={onClose} title="Reading settings">
<div className={row}>
<span className={label}>Text size</span>
<Segmented options={SIZES.map(([l, s]) => [s, l])} value={fontSize} onChange={onFontSize} />
</div>
<div className={row}>
<span className={label}>Theme</span>
<Segmented options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={theme} onChange={onTheme} />
</div>
<div className={row}>
<span className={label}>Verses</span>
<Segmented options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={layout} onChange={onLayout} />
</div>
<div className={row}>
<span className={label}>Width</span>
<Segmented options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
</div>
</Sheet>
);
}
+33
View File
@@ -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 && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />}
<div
role="dialog"
aria-label={typeof title === 'string' ? title : undefined}
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[24rem] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2'
}`}
>
<div className="mx-auto mt-2 h-1 w-9 shrink-0 rounded-full bg-[var(--r-line)] lg:hidden" />
<div className="flex items-center justify-between gap-2 px-4 pb-2 pt-2">
<div className="min-w-0 truncate text-sm font-medium">{title}</div>
<div className="flex items-center gap-1 text-[var(--r-sub)]">
{headerRight}
<button type="button" onClick={onClose} aria-label="Close" className="rounded-lg p-1.5 hover:bg-black/5">
<CloseIcon />
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">{children}</div>
</div>
</>
);
}
+103
View File
@@ -0,0 +1,103 @@
// The single home for per-verse tools: original words, cross-references,
// commentary, and people & places, plus bookmark / copy.
import { useEffect, useState } from 'react';
import Sheet from './Sheet.jsx';
import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js';
const TABS = [['commentary', 'Commentary'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
function OriginalTab({ words }) {
if (!words) return <p className="text-sm text-[var(--r-sub)]">No original-language data for this verse.</p>;
return (
<div className="flex flex-wrap gap-2">
{words.map((w, i) => {
const isHebrew = w.s?.startsWith('H');
const canSpeak = isHebrew || w.s?.startsWith('G');
return (
<div key={i} className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-2 text-center" style={{ minWidth: '4.5rem', maxWidth: '9rem' }}>
<div className={`text-lg leading-tight ${isHebrew ? 'font-serif' : ''}`} dir={isHebrew ? 'rtl' : 'ltr'}>{w.o}</div>
<div className="mt-0.5 text-xs italic text-[var(--r-sub)]">{w.t}</div>
<div className="mt-1 text-xs font-medium">{w.g}</div>
{w.p && <div className="mt-0.5 text-[10px] leading-tight text-[var(--r-sub)]">{w.p}</div>}
{w.s && <div className="mt-0.5 text-[10px] text-[var(--r-sub)]">{w.s}</div>}
{canSpeak && (
<button type="button" onClick={() => speakOriginalWord(w.o, w.s)}
className="mt-1.5 rounded-lg bg-[var(--r-bar)] px-2 py-0.5 text-[11px] hover:bg-black/5"
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>Speak</button>
)}
</div>
);
})}
</div>
);
}
function RefsTab({ refs, loading, onJump }) {
if (loading && !refs) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
if (!refs?.length) return <p className="text-sm text-[var(--r-sub)]">No cross-references for this verse.</p>;
return (
<div className="flex flex-wrap gap-1.5">
{refs.slice(0, 20).map((ref, i) => (
<button key={i} type="button" onClick={() => onJump(ref.book, ref.chapter, ref.verse)}
className="rounded-full border border-[var(--r-line)] px-2.5 py-1 text-xs hover:bg-black/5">
{formatCrossRef(ref)}
</button>
))}
</div>
);
}
export default function VerseSheet({
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose,
}) {
const [tab, setTab] = useState('commentary');
const [copied, setCopied] = useState(false);
useEffect(() => { if (verse && tab === 'refs') onLoadCrossRefs(); }, [verse?.number, tab]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { setCopied(false); }, [verse?.number]);
if (!verse) return null;
const copy = () => {
navigator.clipboard?.writeText(`${bookName} ${chapter}:${verse.number} ${translation} — ${verse.text}`);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
const actionBtn = 'rounded-lg p-1.5 hover:bg-black/5';
return (
<Sheet
open modal={false} pane onClose={onClose}
title={`${bookName} ${chapter}:${verse.number}`}
headerRight={(
<>
<button type="button" onClick={onToggleBookmark} className={`${actionBtn} ${bookmarkColor ? 'text-[var(--r-accent)]' : ''}`} aria-label={bookmarkColor ? 'Remove bookmark' : 'Bookmark verse'}>
<BookmarkIcon filled={!!bookmarkColor} />
</button>
{bookmarkColor && (
<button type="button" onClick={onCycleColor} className={actionBtn} aria-label="Change bookmark colour">
<span className="block h-4 w-4 rounded-full border border-black/10" style={{ backgroundColor: bookmarkColor }} />
</button>
)}
<button type="button" onClick={copy} className={actionBtn} aria-label="Copy verse">
{copied ? <span className="px-1 text-xs">Copied</span> : <CopyIcon />}
</button>
</>
)}
>
<div className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
{TABS.map(([id, label]) => (
<button key={id} type="button" onClick={() => setTab(id)}
className={`shrink-0 rounded-full border px-3 py-1 text-xs ${tab === id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{label}
</button>
))}
</div>
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
{tab === 'original' && <OriginalTab words={words} />}
{tab === 'refs' && <RefsTab refs={crossRefs} loading={crossRefsLoading} onJump={onJump} />}
{tab === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />}
</Sheet>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Small stroke icons for the reader chrome (currentColor, 20px grid).
const base = { viewBox: '0 0 20 20', width: 20, height: 20, fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true };
export const ChevronLeft = () => <svg {...base}><path d="M12 4l-6 6 6 6" /></svg>;
export const ChevronRight = () => <svg {...base}><path d="M8 4l6 6-6 6" /></svg>;
export const ChevronDown = ({ size = 14 }) => <svg {...base} width={size} height={size}><path d="M5 8l5 5 5-5" /></svg>;
export const SearchIcon = () => <svg {...base}><circle cx="9" cy="9" r="5.5" /><path d="M13.5 13.5L17 17" /></svg>;
export const CloseIcon = () => <svg {...base}><path d="M5 5l10 10M15 5L5 15" /></svg>;
export const LinkIcon = () => <svg {...base}><path d="M8.5 11.5a3 3 0 004.2 0l2.3-2.3a3 3 0 00-4.2-4.2L9.7 6.1" /><path d="M11.5 8.5a3 3 0 00-4.2 0L5 10.8A3 3 0 009.2 15l1.1-1.1" /></svg>;
export const PenIcon = () => <svg {...base}><path d="M13.5 3.5l3 3L7 16l-4 1 1-4 9.5-9.5z" /></svg>;
export const HeadphonesIcon = () => <svg {...base}><path d="M3.5 12V10a6.5 6.5 0 0113 0v2" /><rect x="2.5" y="11.5" width="3.5" height="5" rx="1.2" /><rect x="14" y="11.5" width="3.5" height="5" rx="1.2" /></svg>;
export const FocusIcon = () => <svg {...base}><path d="M3 7V4h3M17 7V4h-3M3 13v3h3M17 13v3h-3" /></svg>;
export const CheckIcon = () => <svg {...base}><path d="M4.5 10.5l3.5 3.5 7.5-8" /></svg>;
+18
View File
@@ -0,0 +1,18 @@
// Reader colour themes, applied as CSS variables on the reader root so every
// reader component can use bg-[var(--r-bg)] etc. and stay in sync.
export const READER_THEMES = {
paper: {
label: 'Paper',
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#7d7466', '--r-accent': '#b4532a', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff' },
},
sepia: {
label: 'Sepia',
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#7d6b48', '--r-accent': '#9a4a1e', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff' },
},
dark: {
label: 'Dark',
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#9a9385', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19' },
},
};
export const themeVars = (id) => (READER_THEMES[id] ?? READER_THEMES.paper).vars;
+24
View File
@@ -7,6 +7,8 @@ import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../uti
export const READER_TRANSLATIONS = [
{ id: 'BSB', name: 'Berean Standard Bible' },
{ id: 'ESV', name: 'English Standard Version', licensed: true,
notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' },
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net',
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' },
{ id: 'NLT', name: 'New Living Translation', licensed: true,
@@ -29,6 +31,16 @@ export function useBibleReader(currentPage) {
} catch { return 'BSB'; }
});
const [readerCopyright, setReaderCopyright] = useState('');
const [readerTheme, setReaderThemeState] = useState(() => {
try {
const saved = localStorage.getItem('reader-theme');
return ['paper', 'sepia', 'dark'].includes(saved) ? saved : 'paper';
} catch { return 'paper'; }
});
// 'flow' = verses run together like a printed page; 'verses' = one verse per line
const [readerLayout, setReaderLayoutState] = useState(() => {
try { return localStorage.getItem('reader-layout') === 'verses' ? 'verses' : 'flow'; } catch { return 'flow'; }
});
const [readerVerses, setReaderVerses] = useState([]);
const [readerTotalChapters, setReaderTotalChapters] = useState(1);
const [readerLoading, setReaderLoading] = useState(false);
@@ -108,6 +120,16 @@ export function useBibleReader(currentPage) {
}
};
const setReaderTheme = (id) => {
setReaderThemeState(id);
try { localStorage.setItem('reader-theme', id); } catch {}
};
const setReaderLayout = (id) => {
setReaderLayoutState(id);
try { localStorage.setItem('reader-layout', id); } catch {}
};
const setReaderTranslation = (id) => {
setReaderTranslationState(id);
try { localStorage.setItem('reader-translation', id); } catch {}
@@ -336,6 +358,8 @@ export function useBibleReader(currentPage) {
readerChapter, setReaderChapter,
readerTranslation, setReaderTranslation,
readerCopyright,
readerTheme, setReaderTheme,
readerLayout, setReaderLayout,
readerVerses,
readerTotalChapters,
readerLoading,
+217 -758
View File
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { vi, test, expect } from 'vitest';
const noop = () => {};
const ctx = {
goHome: noop,
readerBookAbbrev: 'JHN', setReaderBookAbbrev: noop,
readerTranslation: 'ESV', setReaderTranslation: noop,
readerTheme: 'paper', setReaderTheme: noop,
readerLayout: 'flow', setReaderLayout: noop,
readerChapter: 3, setReaderChapter: noop,
readerVerses: [{ number: 1, text: 'Now there was a man of the Pharisees.' }, { number: 2, text: 'This man came to Jesus by night.' }],
readerTotalChapters: 21, readerLoading: false, readerError: '',
readerInterlinear: null, readerSelectedVerse: null, setReaderSelectedVerse: vi.fn(),
readerFontSize: 1, setReaderFontSize: noop, readerBookmarks: {},
readerCrossRefs: null, setReaderCrossRefs: noop, readerCrossRefsLoading: false,
readerShowCrossRefs: false, setReaderShowCrossRefs: noop,
readerSearch: '', setReaderSearch: noop, setReaderSearchActive: noop,
readerSearchScope: 'chapter', setReaderSearchScope: noop,
readerAudioState: { status: 'idle' }, audioProgress: { currentTime: 0, duration: 0 },
bibleIndexStatus: 'idle', audioNarrator: 'david', setAudioNarrator: noop,
_bibleIndexCacheRef: { current: {} },
readerGoToPreviousChapter: noop, readerGoToNextChapter: noop, jumpToReaderVerse: noop,
toggleReaderBookmark: noop, cycleBookmarkColor: noop, loadReaderCrossRefs: noop, loadBibleIndex: noop,
handlePlayReaderAudio: noop, handleToggleReaderAudioPause: noop, handleStopBookAudio: noop,
handleSeekAudio: noop, handleSeekAudioTo: noop,
readerInkByPage: {}, updateReaderPageInk: noop, readerTextHighlights: [], addTextHighlight: noop, removeTextHighlight: noop,
planProgress: [], toggleDay: noop,
};
vi.mock('../context/AppContext.js', () => ({ useApp: () => ctx }));
import ReaderPage from './ReaderPage.jsx';
test('renders the chapter, chrome and opens the tool sheets', () => {
render(<ReaderPage />);
expect(screen.getByText('John 3 · ESV')).toBeTruthy();
expect(screen.getByText(/man of the Pharisees/)).toBeTruthy();
fireEvent.click(screen.getByText('John 3 · ESV'));
expect(screen.getByText('Go to')).toBeTruthy();
fireEvent.click(screen.getByLabelText('Close'));
fireEvent.click(screen.getByLabelText('Reading settings'));
expect(screen.getByText('Text size')).toBeTruthy();
fireEvent.click(screen.getByLabelText('Close'));
fireEvent.click(screen.getByLabelText('Search'));
expect(screen.getByPlaceholderText(/Search John 3/)).toBeTruthy();
});
test('tapping a verse selects it', () => {
render(<ReaderPage />);
fireEvent.click(screen.getByText(/This man came to Jesus/));
expect(ctx.setReaderSelectedVerse).toHaveBeenCalledWith(2);
});