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={(
+ 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)]">
+ David
+ Hays
+ Souer
+
+ )}
+ />
+
+ );
+}
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) => (
+
+
+ onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
+ {e.bookName} {e.chapter}:{e.verse}
+
+ onRemove(e.key)} aria-label="Remove bookmark" className="rounded p-1 text-[var(--r-sub)] hover:text-red-600">
+
+ ))}
+
+ );
+}
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) => (
+ 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}
+
+ );
+
+ return (
+ setPickedBook(null)} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">‹ All books · {book.name}
+ ) : 'Go to'}>
+ {!book && (
+ <>
+
+ {READER_TRANSLATIONS.map((t) => (
+ 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}
+
+ ))}
+
+ 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) => (
+ { 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}
+
+ ))}
+
+ )}
+
+ );
+}
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) => (
+
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'}>
+ {m.done ? '✓' : '●'} {m.plan.title} · Day {m.dayIndex + 1} of {m.plan.days}
+ {m.total > 1 ? `${m.position} of ${m.total}` : m.done ? 'Read' : 'Mark read'}
+
+ ))}
+
+ );
+}
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 (
+
+
+
+
+ {title}
+
+
+
+ 0} />
+
+ Aa
+
+
+ );
+}
+
+export function BottomBar({
+ onPrev, onNext, prevDisabled, nextDisabled,
+ onAudio, audioActive, onPen, onCrossRefs, crossRefsOn, onFocus, primary,
+}) {
+ const active = 'text-[var(--r-accent)]';
+ return (
+
+
+
+
+
+
+
+
+ {primary && (
+
+ {primary.done ? null : }{primary.label}
+
+ )}
+
+
+
+ );
+}
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) => (
+ {label}
+ );
+
+ 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) => (
+ onJump(currentBook, chapter, v.number)}
+ className="block w-full rounded-lg px-2 py-2 text-left text-sm hover:bg-black/5">
+ {v.number} {snippet(v.text, q)}
+
+ ))
+ )}
+
+ {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. Try again
}
+ {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) => (
+ 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">
+ {v.bookName} {v.chapter}:{v.verse} {snippet(v.text, deferredBibleQuery)}
+
+ ))}
+ >
+ ))}
+ >
+ )}
+
+ );
+}
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]) => (
+ 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}
+
+ ))}
+
+ );
+}
+
+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 &&
}
+
+
+
+
{title}
+
+ {headerRight}
+
+
+
+
+
+
{children}
+
+ >
+ );
+}
diff --git a/src/components/reader/VerseSheet.jsx b/src/components/reader/VerseSheet.jsx
new file mode 100644
index 0000000..620146e
--- /dev/null
+++ b/src/components/reader/VerseSheet.jsx
@@ -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 No original-language data for this verse.
;
+ return (
+
+ {words.map((w, i) => {
+ const isHebrew = w.s?.startsWith('H');
+ const canSpeak = isHebrew || w.s?.startsWith('G');
+ return (
+
+
{w.o}
+
{w.t}
+
{w.g}
+ {w.p &&
{w.p}
}
+ {w.s &&
{w.s}
}
+ {canSpeak && (
+
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
+ )}
+
+ );
+ })}
+
+ );
+}
+
+function RefsTab({ refs, loading, onJump }) {
+ if (loading && !refs) return Loading…
;
+ if (!refs?.length) return No cross-references for this verse.
;
+ return (
+
+ {refs.slice(0, 20).map((ref, i) => (
+ 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)}
+
+ ))}
+
+ );
+}
+
+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 (
+
+
+
+
+ {bookmarkColor && (
+
+
+
+ )}
+
+ {copied ? Copied : }
+
+ >
+ )}
+ >
+
+ {TABS.map(([id, label]) => (
+ 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}
+
+ ))}
+
+ {tab === 'commentary' && }
+ {tab === 'original' && }
+ {tab === 'refs' && }
+ {tab === 'people' && }
+
+ );
+}
diff --git a/src/components/reader/icons.jsx b/src/components/reader/icons.jsx
new file mode 100644
index 0000000..5d1c265
--- /dev/null
+++ b/src/components/reader/icons.jsx
@@ -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 = () => ;
+export const ChevronRight = () => ;
+export const ChevronDown = ({ size = 14 }) => ;
+export const SearchIcon = () => ;
+export const CloseIcon = () => ;
+export const LinkIcon = () => ;
+export const PenIcon = () => ;
+export const HeadphonesIcon = () => ;
+export const FocusIcon = () => ;
+export const CheckIcon = () => ;
diff --git a/src/components/reader/readerThemes.js b/src/components/reader/readerThemes.js
new file mode 100644
index 0000000..d5a6d0d
--- /dev/null
+++ b/src/components/reader/readerThemes.js
@@ -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;
diff --git a/src/hooks/useBibleReader.js b/src/hooks/useBibleReader.js
index 2f85a8b..637734e 100644
--- a/src/hooks/useBibleReader.js
+++ b/src/hooks/useBibleReader.js
@@ -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,
diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx
index 4f5abff..471d2ec 100644
--- a/src/pages/ReaderPage.jsx
+++ b/src/pages/ReaderPage.jsx
@@ -1,26 +1,33 @@
+// The reading side of the app: a distraction-free page with slim chrome. Every
+// tool (navigation, search, settings, audio, verse study) lives in a sheet.
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
import { useApp } from '../context/AppContext.js';
-import { formatCrossRef, speakOriginalWord } from '../utils/wordStudy.js';
-import AuthStatus from '../components/AuthStatus.jsx';
-import AudioPlayer from '../components/AudioPlayer.jsx';
-import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
-import { getPlan, getPlanSchedule, findDayIndexForRef } from '../utils/readingPlans.js';
+import { bookOptions } from '../utils/bibleUtils.jsx';
+import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings } from '../utils/readingPlans.js';
import DrawCanvas from './DrawCanvas.jsx';
-import StudyPanel from '../components/StudyPanel.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
+import { themeVars } from '../components/reader/readerThemes.js';
+import { TopBar, BottomBar } from '../components/reader/ReaderBars.jsx';
+import ReaderText from '../components/reader/ReaderText.jsx';
+import PlanStrip from '../components/reader/PlanStrip.jsx';
+import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
+import SettingsSheet from '../components/reader/SettingsSheet.jsx';
+import SearchSheet from '../components/reader/SearchSheet.jsx';
+import BookmarksSheet from '../components/reader/BookmarksSheet.jsx';
+import AudioSheet from '../components/reader/AudioSheet.jsx';
+import VerseSheet from '../components/reader/VerseSheet.jsx';
+import { FocusIcon } from '../components/reader/icons.jsx';
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
-
-// Copies "Book 3:16 NLT — text" to the clipboard.
-const copyVerse = (bookName, chapterNum, verseNum, text, translation) => {
- navigator.clipboard?.writeText(`${bookName} ${chapterNum}:${verseNum} ${translation} — ${text}`);
-};
+const HIGHLIGHT_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'];
export default function ReaderPage() {
const {
goHome,
- readerBookAbbrev,
+ readerBookAbbrev, setReaderBookAbbrev,
readerTranslation, setReaderTranslation,
+ readerTheme, setReaderTheme,
+ readerLayout, setReaderLayout,
readerChapter, setReaderChapter,
readerVerses,
readerTotalChapters,
@@ -30,19 +37,17 @@ export default function ReaderPage() {
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerBookmarks,
- readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
- readerCrossRefs,
+ readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
readerShowCrossRefs, setReaderShowCrossRefs,
readerSearch, setReaderSearch,
- readerSearchActive, setReaderSearchActive,
+ setReaderSearchActive,
readerSearchScope, setReaderSearchScope,
readerAudioState,
audioProgress,
bibleIndexStatus,
audioNarrator, setAudioNarrator,
_bibleIndexCacheRef,
- handleReaderBookChange,
readerGoToPreviousChapter,
readerGoToNextChapter,
jumpToReaderVerse,
@@ -55,7 +60,6 @@ export default function ReaderPage() {
handleStopBookAudio,
handleSeekAudio,
handleSeekAudioTo,
- setReaderCrossRefs,
readerInkByPage,
updateReaderPageInk,
readerTextHighlights,
@@ -68,6 +72,16 @@ export default function ReaderPage() {
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
const readerNotice = READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.notice ?? '';
+ const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'audio'
+ const [chromeVisible, setChromeVisible] = useState(true);
+ const [readerDrawMode, setReaderDrawMode] = useState(false);
+ const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
+ const [selectionPicker, setSelectionPicker] = useState(null);
+ const swipeTouchRef = useRef(null);
+ const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
+
+ useEffect(() => { localStorage.setItem('reader-wide', readerWideLayout ? '1' : '0'); }, [readerWideLayout]);
+
// Active reading plans whose schedule includes the chapter currently on screen.
const planMatches = useMemo(() => {
return planProgress
@@ -75,9 +89,12 @@ export default function ReaderPage() {
.map((p) => {
const plan = getPlan(p.planId);
if (!plan) return null;
- const dayIndex = findDayIndexForRef(getPlanSchedule(p.planId), readerBookAbbrev, readerChapter);
+ const schedule = getPlanSchedule(p.planId);
+ const dayIndex = findDayIndexForRef(schedule, readerBookAbbrev, readerChapter);
if (dayIndex === -1) return null;
- return { progressId: p.id, plan, dayIndex, done: p.completedDays.includes(dayIndex) };
+ const refs = schedule[dayIndex];
+ const position = refs.findIndex((r) => r.book === readerBookAbbrev && r.chapter === readerChapter) + 1;
+ return { progressId: p.id, plan, dayIndex, refs, position, total: refs.length, done: p.completedDays.includes(dayIndex), nextDayRefs: schedule[dayIndex + 1] ?? null };
})
.filter(Boolean);
}, [planProgress, readerBookAbbrev, readerChapter]);
@@ -93,28 +110,43 @@ export default function ReaderPage() {
const justMarked = planMatchesRef.current.filter((m) => !m.done);
if (justMarked.length === 0) return;
justMarked.forEach((m) => toggleDay(m.progressId, m.dayIndex, m.plan.days));
- setAutoMarkedNotice(`✓ Marked read — ${justMarked.map((m) => m.plan.title).join(', ')}`);
+ setAutoMarkedNotice(`Marked read: ${justMarked.map((m) => m.plan.title).join(', ')}`);
window.setTimeout(() => setAutoMarkedNotice(''), 4000);
}, 20000);
return () => window.clearTimeout(timer);
}, [readerBookAbbrev, readerChapter, toggleDay]);
- const [readerDrawMode, setReaderDrawMode] = useState(false);
- const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
- const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
- const [selectionPicker, setSelectionPicker] = useState(null);
- const [studyVerse, setStudyVerse] = useState(null); // verse number with the study panel open (normal reading mode)
+ const goTo = (book, chapter) => {
+ setReaderBookAbbrev(book);
+ setReaderChapter(chapter);
+ setReaderSelectedVerse(null);
+ setReaderCrossRefs(null);
+ window.scrollTo({ top: 0 });
+ };
+
+ // Bottom-bar action tied to the first plan that includes this chapter: step through
+ // the day's chapters, then mark the day read and roll into the next day.
+ const primary = useMemo(() => {
+ const m = planMatches[0];
+ if (!m) return null;
+ const label = (ref) => formatDayReadings([ref]);
+ if (m.position < m.total) {
+ const next = m.refs[m.position];
+ return { label: `Next: ${label(next)}`, done: true, onClick: () => goTo(next.book, next.chapter) };
+ }
+ const markDone = () => { if (!m.done) toggleDay(m.progressId, m.dayIndex, m.plan.days); };
+ if (m.nextDayRefs?.length) {
+ const first = m.nextDayRefs[0];
+ return { label: m.done ? `Day ${m.dayIndex + 2}: ${label(first)}` : `Done · Day ${m.dayIndex + 2}`, done: m.done, onClick: () => { markDone(); goTo(first.book, first.chapter); } };
+ }
+ return m.done ? null : { label: 'Finish plan', done: false, onClick: markDone };
+ }, [planMatches]); // eslint-disable-line react-hooks/exhaustive-deps
// Whole-Bible search: lowercase the ~31k-verse index once (not per keystroke),
// and let typing stay responsive while results catch up via useDeferredValue.
const bibleIndex = bibleIndexStatus === 'ready' ? (_bibleIndexCacheRef.current.BSB ?? null) : null;
- const bibleIndexLower = useMemo(
- () => (bibleIndex ? bibleIndex.map((v) => v.text.toLowerCase()) : null),
- [bibleIndex],
- );
- const deferredBibleQuery = useDeferredValue(
- readerSearchActive && readerSearchScope === 'bible' ? readerSearch.trim().toLowerCase() : '',
- );
+ const bibleIndexLower = useMemo(() => (bibleIndex ? bibleIndex.map((v) => v.text.toLowerCase()) : null), [bibleIndex]);
+ const deferredBibleQuery = useDeferredValue(sheet === 'search' && readerSearchScope === 'bible' ? readerSearch.trim().toLowerCase() : '');
const bibleMatches = useMemo(() => {
if (!bibleIndex || !deferredBibleQuery) return [];
const out = [];
@@ -123,53 +155,18 @@ export default function ReaderPage() {
}
return out;
}, [bibleIndex, bibleIndexLower, deferredBibleQuery]);
- const swipeTouchRef = useRef(null);
- const [toolbarVisible, setToolbarVisible] = useState(true);
- const [readerPageMode, setReaderPageMode] = useState(() => localStorage.getItem('reader-page-mode') === '1');
- const [flipPage, setFlipPage] = useState(0);
- const [flipTotalPages, setFlipTotalPages] = useState(1);
- const [flipPageWidth, setFlipPageWidth] = useState(0);
- const flipContainerRef = useRef(null);
- const flipContentRef = useRef(null);
+ const openSheet = (name) => {
+ setSheet(name);
+ if (name === 'search') setReaderSearchActive(true);
+ };
+ const closeSheet = () => {
+ if (sheet === 'search') { setReaderSearch(''); setReaderSearchActive(false); }
+ setSheet(null);
+ };
+ const jump = (book, chapter, verse) => { setSheet(null); jumpToReaderVerse(book, chapter, verse); };
- useEffect(() => {
- localStorage.setItem('reader-wide', readerWideLayout ? '1' : '0');
- // Auto-collapse toolbar when entering wide mode, restore when leaving
- setToolbarVisible(!readerWideLayout);
- }, [readerWideLayout]);
-
- useEffect(() => {
- localStorage.setItem('reader-page-mode', readerPageMode ? '1' : '0');
- }, [readerPageMode]);
-
- // Reset to page 0 when navigating chapters
- useEffect(() => {
- setFlipPage(0);
- }, [readerChapter, readerBookAbbrev]);
-
- // Track container width for CSS column sizing
- useEffect(() => {
- if (!readerPageMode || !flipContainerRef.current) return;
- const el = flipContainerRef.current;
- const update = () => setFlipPageWidth(el.clientWidth);
- update();
- const ro = new ResizeObserver(update);
- ro.observe(el);
- return () => ro.disconnect();
- }, [readerPageMode, readerVerses]);
-
- // Count pages after content renders
- useEffect(() => {
- if (!readerPageMode || !flipContentRef.current || !flipPageWidth) return;
- const t = setTimeout(() => {
- const sw = flipContentRef.current?.scrollWidth ?? 0;
- setFlipTotalPages(Math.max(1, Math.round(sw / flipPageWidth)));
- }, 120);
- return () => clearTimeout(t);
- }, [readerPageMode, readerVerses, readerFontSize, flipPageWidth]);
-
- // Swipe left/right to navigate chapters
+ // Swipe left/right to change chapter
const handleTouchStart = (e) => {
if (readerDrawMode) return;
const t = e.changedTouches[0];
@@ -182,30 +179,20 @@ export default function ReaderPage() {
const dy = t.clientY - swipeTouchRef.current.y;
swipeTouchRef.current = null;
if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx) * 0.8) return;
- if (dx < 0) {
- if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1);
- else readerGoToNextChapter();
- } else {
- if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1);
- else readerGoToPreviousChapter();
- }
+ if (dx < 0) readerGoToNextChapter(); else readerGoToPreviousChapter();
};
- // Arrow keys for desktop
+ // Arrow keys for desktop; Escape closes the open sheet or verse
useEffect(() => {
const handleKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
- if (e.key === 'ArrowLeft') {
- if (readerPageMode && flipPage > 0) setFlipPage((p) => p - 1);
- else readerGoToPreviousChapter();
- } else if (e.key === 'ArrowRight') {
- if (readerPageMode && flipPage < flipTotalPages - 1) setFlipPage((p) => p + 1);
- else readerGoToNextChapter();
- }
+ if (e.key === 'ArrowLeft') readerGoToPreviousChapter();
+ else if (e.key === 'ArrowRight') readerGoToNextChapter();
+ else if (e.key === 'Escape') { if (sheet) closeSheet(); else setReaderSelectedVerse(null); }
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
- }, [readerGoToPreviousChapter, readerGoToNextChapter, readerPageMode, flipPage, flipTotalPages]);
+ });
// Dismiss the highlight picker when tapping outside it
useEffect(() => {
@@ -216,6 +203,7 @@ export default function ReaderPage() {
}, [selectionPicker]);
// Render verse text with colored highlights and optional search match highlight
+ const chapterSearch = sheet === 'search' && readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
function renderVerseText(verseNum, text, searchLow) {
const spans = [];
for (const h of (readerTextHighlights || [])) {
@@ -240,17 +228,13 @@ export default function ReaderPage() {
if (s.start > cursor) parts.push(text.slice(cursor, s.start));
if (s.type === 'hl') {
parts.push(
- removeTextHighlight(s.id)} title="Click to remove highlight">
+ { e.stopPropagation(); removeTextHighlight(s.id); }} title="Tap to remove highlight">
{text.slice(s.start, s.end)}
-
+ ,
);
} else {
- parts.push(
-
- {text.slice(s.start, s.end)}
-
- );
+ parts.push({text.slice(s.start, s.end)} );
}
cursor = s.end;
}
@@ -271,634 +255,96 @@ export default function ReaderPage() {
while (node && !(node.dataset && node.dataset.verseNum)) node = node.parentElement;
if (!node) return;
const verseNum = Number(node.dataset.verseNum);
- const verse = readerVerses.find(v => v.number === verseNum);
+ const verse = readerVerses.find((v) => v.number === verseNum);
if (!verse) return;
const selectedText = sel.toString();
const startOffset = verse.text.indexOf(selectedText);
if (startOffset === -1) return;
- setSelectionPicker({
- x: (rect.left + rect.right) / 2,
- y: rect.top,
- verse: verseNum,
- startOffset,
- endOffset: startOffset + selectedText.length,
- });
+ setSelectionPicker({ x: (rect.left + rect.right) / 2, y: rect.top, verse: verseNum, startOffset, endOffset: startOffset + selectedText.length });
}, 20);
}
const bookmarkEntries = Object.entries(readerBookmarks).map(([key, color]) => {
const [bAbbrev, chapterStr, verseStr] = key.split('-');
const bookIndex = bookOptions.findIndex((b) => b.abbrev === bAbbrev);
- return {
- key, color,
- bookAbbrev: bAbbrev,
- bookName: bookOptions[bookIndex]?.name ?? bAbbrev,
- chapter: Number(chapterStr),
- verse: Number(verseStr),
- bookIndex,
- };
+ return { key, color, bookAbbrev: bAbbrev, bookName: bookOptions[bookIndex]?.name ?? bAbbrev, chapter: Number(chapterStr), verse: Number(verseStr), bookIndex };
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse);
+ const selectedVerse = readerSelectedVerse != null ? readerVerses.find((v) => v.number === readerSelectedVerse) : null;
+ const selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null;
+ const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`;
+ const audio = {
+ state: readerAudioState, progress: audioProgress,
+ play: handlePlayReaderAudio, togglePause: handleToggleReaderAudioPause,
+ seek: handleSeekAudio, seekTo: handleSeekAudioTo, stop: handleStopBookAudio,
+ };
+ const paneOpen = !!selectedVerse && !readerDrawMode;
+
return (
-
-
-
-
-
Bible Study Project
-
Read the Bible ({readerTranslation})
-
-
-
- ← Back to Studies
-
-
-
-
-
-
- {/* Collapsible toolbar — hidden in wide focus mode */}
-
+
+ {chromeVisible && (
+
openSheet('nav')}
+ onSearch={() => openSheet('search')}
+ onBookmarks={() => openSheet('bookmarks')}
+ bookmarkCount={bookmarkEntries.length}
+ onSettings={() => openSheet('settings')}
+ />
+ )}
+ {!chromeVisible && (
+ setChromeVisible(true)} aria-label="Show controls"
+ className="fixed right-3 top-3 z-30 rounded-full border border-[var(--r-line)] bg-[var(--r-bar)] p-2 text-[var(--r-sub)] opacity-70 hover:opacity-100">
+
+
+ )}
- {/* Navigation + tools bar */}
-
-
- Translation{' '}
- setReaderTranslation(e.target.value)}
- className="rounded-xl border border-slate-300 px-2 py-1 text-sm"
- >
- {READER_TRANSLATIONS.map((t) => {t.id} )}
-
-
-
- Book{' '}
- handleReaderBookChange(e.target.value)}
- className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
- >
- {bookOptions.map((b) => (
- {b.name}
- ))}
-
-
-
- Chapter{' '}
- { setReaderChapter(Number(e.target.value)); setReaderSelectedVerse(null); setReaderCrossRefs(null); setReaderSearch(''); setReaderSearchActive(false); }}
- className="ml-1 rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
- >
- {Array.from({ length: readerTotalChapters }, (_, i) => i + 1).map((num) => (
- {num}
- ))}
-
-
-
- {planMatches.map(({ progressId, plan, dayIndex, done }) => (
- toggleDay(progressId, dayIndex, plan.days)}
- className={`rounded-xl px-3 py-1.5 text-sm font-semibold transition ${done ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'border border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-50'}`}
- title={done ? `Click to unmark Day ${dayIndex + 1} of "${plan.title}"` : `Mark Day ${dayIndex + 1} of "${plan.title}" as read`}
- >
- {done ? `✓ ${plan.title}` : `Mark read — ${plan.title}`}
-
- ))}
-
- ‹ Prev
-
- = readerTotalChapters}
- className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
- Next ›
-
-
-
+ {chromeVisible && toggleDay(m.progressId, m.dayIndex, m.plan.days)} />}
- {autoMarkedNotice && (
-
- {autoMarkedNotice}
-
- )}
-
- {/* Tool strip: font size · cross-refs · search */}
-
-
Text size
- {[['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25]].map(([label, size]) => (
-
setReaderFontSize(size)}
- className={`rounded-lg px-2.5 py-1 text-xs font-semibold transition ${readerFontSize === size ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
- {label}
-
- ))}
-
-
{ setReaderShowCrossRefs((v) => !v); if (!readerShowCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
- className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerShowCrossRefs ? 'bg-amber-100 text-amber-800' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
- {readerCrossRefsLoading ? 'Loading refs…' : '🔗 Cross-Refs'}
-
-
-
-
setReaderBookmarksPanelOpen((v) => !v)}
- className={`flex items-center gap-1.5 rounded-lg px-3 py-1 text-xs font-semibold transition ${readerBookmarksPanelOpen ? 'bg-sky-100 text-sky-800' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}>
- 0} />
- Bookmarks{bookmarkEntries.length > 0 ? ` (${bookmarkEntries.length})` : ''}
-
- {readerBookmarksPanelOpen && (
-
- {bookmarkEntries.length === 0 ? (
-
- No bookmarks yet. Tap the bookmark icon next to any verse to save it here.
-
- ) : (
-
- {bookmarkEntries.map((entry) => (
-
-
- jumpToReaderVerse(entry.bookAbbrev, entry.chapter, entry.verse)}
- className="flex-1 truncate text-left text-sm font-medium text-slate-700 hover:text-sky-700">
- {entry.bookName} {entry.chapter}:{entry.verse}
-
- toggleReaderBookmark(entry.key)}
- title="Remove bookmark"
- className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-rose-600">
- ✕
-
-
- ))}
-
- )}
-
- )}
-
-
- {readerSearchActive ? (
-
-
- setReaderSearchScope('chapter')}
- className={`px-2 py-1 text-xs font-semibold transition ${readerSearchScope === 'chapter' ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}>
- This chapter
-
- { setReaderSearchScope('bible'); if (bibleIndexStatus === 'idle') loadBibleIndex(); }}
- className={`px-2 py-1 text-xs font-semibold transition ${readerSearchScope === 'bible' ? 'bg-slate-900 text-white' : 'bg-white text-slate-600 hover:bg-slate-50'}`}>
- Whole Bible (BSB)
-
-
-
setReaderSearch(e.target.value)}
- placeholder={readerSearchScope === 'bible' ? 'Search the whole Bible…' : 'Search this chapter…'}
- className="rounded-xl border border-slate-300 bg-slate-50 px-3 py-1 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
- />
-
{ setReaderSearch(''); setReaderSearchActive(false); }}
- className="rounded-lg border border-slate-300 px-2 py-1 text-xs text-slate-500 hover:bg-slate-50">✕
-
- ) : (
-
setReaderSearchActive(true)}
- className="rounded-lg border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
- 🔍 Search
-
- )}
-
-
setReaderDrawMode((v) => !v)}
- className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerDrawMode ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
- >
- ✏ Draw
-
-
setReaderWideLayout((v) => !v)}
- className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerWideLayout ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
- >
- ⊞ Wide
-
- {readerWideLayout && (
-
setToolbarVisible(false)}
- className="rounded-lg px-3 py-1 text-xs font-semibold border border-slate-300 text-slate-600 hover:bg-slate-50 transition"
- >
- ✕ Hide
-
- )}
- {!readerDrawMode && (
-
setReaderPageMode((v) => !v)}
- className={`rounded-lg px-3 py-1 text-xs font-semibold transition ${readerPageMode ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-600 hover:bg-slate-50'}`}
- >
- ⎕ Page
-
- )}
-
-
- {/* end collapsible toolbar */}
-
- {/* Whole-Bible search results */}
- {readerSearchActive && readerSearchScope === 'bible' && (
-
- {bibleIndexStatus === 'loading' && (
-
Loading the full Bible for search — this happens once per visit (~7MB)…
- )}
- {bibleIndexStatus === 'error' && (
-
Couldn't load the full Bible for search. Try again
- )}
- {bibleIndexStatus === 'ready' && (() => {
- const q = deferredBibleQuery;
- if (!q) return
Type at least a word to search all 66 books.
;
- const matches = bibleMatches;
- if (matches.length === 0) return
No verses match "{readerSearch}".
;
- const shown = matches.slice(0, 100);
- return (
-
-
- {matches.length} match{matches.length === 1 ? '' : 'es'}{matches.length > shown.length ? ` (showing first ${shown.length})` : ''}
-
-
- {shown.map((v) => {
- const idx = v.text.toLowerCase().indexOf(q);
- return (
- jumpToReaderVerse(v.bookAbbrev, v.chapter, v.verse)}
- className="block w-full rounded-xl px-3 py-2 text-left text-sm hover:bg-slate-50">
- {v.bookName} {v.chapter}:{v.verse} {' '}
-
- {v.text.slice(0, idx)}
- {v.text.slice(idx, idx + q.length)}
- {v.text.slice(idx + q.length)}
-
-
- );
- })}
-
-
- );
- })()}
-
- )}
-
- {/* Audio player */}
-
-
handleSeekAudio(-10)}
- onSeekForward={() => handleSeekAudio(10)}
- onSeekTo={handleSeekAudioTo}
- onNext={readerGoToNextChapter}
- nextDisabled={readerChapter >= readerTotalChapters}
- onStop={handleStopBookAudio}
- controls={(
- setAudioNarrator(e.target.value)}
- className="rounded-xl border border-slate-300 bg-white px-2 py-1.5 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200">
- David
- Hays
- Souer
-
- )}
+
+ {!readerDrawMode && (
+ t.id === readerTranslation)?.name ?? readerTranslation}
+ notice={readerNotice}
+ verses={readerVerses}
+ loading={readerLoading}
+ error={readerError}
+ layout={readerLayout}
+ fontSize={readerFontSize}
+ wide={readerWideLayout}
+ bookmarks={readerBookmarks}
+ bookKey={readerBookAbbrev}
+ chapter={readerChapter}
+ selectedVerse={readerSelectedVerse}
+ onSelectVerse={setReaderSelectedVerse}
+ renderVerseText={renderVerseText}
+ searchLower={chapterSearch}
+ crossRefs={readerCrossRefs}
+ showCrossRefs={readerShowCrossRefs}
+ onSelectionEnd={handleSelectionEnd}
/>
-
-
- {/* Verses — normal scroll mode */}
- {!readerDrawMode && !readerPageMode && (
-
-
- {readerBook?.name} {readerChapter} ({readerTranslation})
-
- {readerNotice &&
{readerNotice}
}
- {readerInterlinear && (
-
Click a verse number to see original words & pronunciation · bookmark icon to save · copy icon to copy
- )}
- {!readerInterlinear && (
-
Hover a verse for actions
- )}
- {readerLoading &&
Loading…
}
- {readerError &&
{readerError}
}
- {!readerLoading && !readerError && (() => {
- const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
- const filtered = searchLower
- ? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
- : readerVerses;
- if (searchLower && filtered.length === 0) {
- return
No verses match "{readerSearch}".
;
- }
- return (
-
- {filtered.map((verse) => {
- const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
- const verseWords = chapterInterlinear?.[String(verse.number)];
- const isOpen = readerSelectedVerse === verse.number;
- const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`;
- const bmColor = readerBookmarks[verseKey];
- const crossRefs = readerCrossRefs?.[verse.number];
-
- return (
-
-
-
setReaderSelectedVerse(isOpen ? null : verse.number)}
- className={`mt-0.5 shrink-0 rounded px-1 text-xs font-bold transition ${
- verseWords
- ? isOpen ? 'bg-sky-600 text-white' : 'text-sky-600 hover:bg-sky-50'
- : 'cursor-default text-slate-400'
- }`}
- title={verseWords ? 'Show original words' : undefined}>
- {verse.number}
-
-
{renderVerseText(verse.number, verse.text, searchLower)}
-
- toggleReaderBookmark(verseKey)}
- className={`rounded p-1 leading-none hover:bg-slate-100 ${bmColor ? 'text-amber-600' : 'text-slate-400'}`}
- title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}>
-
-
- {bmColor && (
- cycleBookmarkColor(verseKey)}
- className="rounded p-1 text-sm leading-none hover:bg-slate-100"
- title="Change highlight colour">
- 🎨
-
- )}
- setStudyVerse(studyVerse === verse.number ? null : verse.number)}
- className={`rounded p-1 text-sm leading-none hover:bg-slate-100 ${studyVerse === verse.number ? 'bg-indigo-100' : ''}`}
- title="Commentary, people & places">
- 📚
-
- copyVerse(readerBook?.name, readerChapter, verse.number, verse.text, readerTranslation)}
- className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
- title="Copy verse">
-
-
-
-
-
- {studyVerse === verse.number && (
-
setStudyVerse(null)} />
- )}
-
- {readerShowCrossRefs && crossRefs && (
-
- {crossRefs.slice(0, 8).map((ref, i) => {
- const label = formatCrossRef(ref);
- return (
-
- {label}
-
- );
- })}
-
- )}
-
- {isOpen && verseWords && (
-
-
- {verseWords.map((w, i) => {
- const isHebrew = w.s?.startsWith('H');
- const canSpeak = isHebrew || w.s?.startsWith('G');
- return (
-
-
- {w.o}
-
-
{w.t}
-
{w.g}
- {w.p &&
{w.p}
}
- {w.s &&
{w.s}
}
- {canSpeak && (
-
speakOriginalWord(w.o, w.s)}
- className="mt-1.5 rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
- title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>
- 🔊 Speak
-
- )}
-
- );
- })}
-
-
- )}
-
- );
- })}
-
- );
- })()}
-
)}
- {/* Verses — page-flip mode */}
- {!readerDrawMode && readerPageMode && (
-
- {readerLoading &&
Loading…
}
- {readerError &&
{readerError}
}
- {!readerLoading && !readerError && (() => {
- const searchLower = readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
- const filtered = searchLower
- ? readerVerses.filter((v) => v.text.toLowerCase().includes(searchLower))
- : readerVerses;
- if (searchLower && filtered.length === 0) {
- return
No verses match "{readerSearch}".
;
- }
- return (
-
-
-
- {readerBook?.name} {readerChapter} ({readerTranslation})
-
- {readerNotice &&
{readerNotice}
}
- {readerInterlinear
- ?
Click a verse number to see original words & pronunciation
- :
Hover a verse for actions
- }
-
- {filtered.map((verse) => {
- const chapterInterlinear = readerInterlinear?.[String(readerChapter)];
- const verseWords = chapterInterlinear?.[String(verse.number)];
- const isOpen = readerSelectedVerse === verse.number;
- const verseKey = `${readerBookAbbrev}-${readerChapter}-${verse.number}`;
- const bmColor = readerBookmarks[verseKey];
- const crossRefs = readerCrossRefs?.[verse.number];
-
- return (
-
-
-
setReaderSelectedVerse(isOpen ? null : verse.number)}
- className={`mt-0.5 shrink-0 rounded px-1 text-xs font-bold transition ${
- verseWords
- ? isOpen ? 'bg-sky-600 text-white' : 'text-sky-600 hover:bg-sky-50'
- : 'cursor-default text-slate-400'
- }`}
- title={verseWords ? 'Show original words' : undefined}>
- {verse.number}
-
-
{renderVerseText(verse.number, verse.text, searchLower)}
-
- toggleReaderBookmark(verseKey)}
- className={`rounded p-1 leading-none hover:bg-slate-100 ${bmColor ? 'text-amber-600' : 'text-slate-400'}`}
- title={bmColor ? 'Remove bookmark' : 'Bookmark this verse'}>
-
-
- {bmColor && (
- cycleBookmarkColor(verseKey)}
- className="rounded p-1 text-sm leading-none hover:bg-slate-100"
- title="Change highlight colour">
- 🎨
-
- )}
- copyVerse(readerBook?.name, readerChapter, verse.number, verse.text, readerTranslation)}
- className="rounded p-1 leading-none text-slate-400 hover:bg-slate-100 hover:text-slate-700"
- title="Copy verse">
-
-
-
-
-
- {readerShowCrossRefs && crossRefs && (
-
- {crossRefs.slice(0, 8).map((ref, i) => {
- const label = formatCrossRef(ref);
- return (
-
- {label}
-
- );
- })}
-
- )}
-
- {isOpen && verseWords && (
-
-
- {verseWords.map((w, i) => {
- const isHebrew = w.s?.startsWith('H');
- const canSpeak = isHebrew || w.s?.startsWith('G');
- return (
-
-
- {w.o}
-
-
{w.t}
-
{w.g}
- {w.p &&
{w.p}
}
- {w.s &&
{w.s}
}
- {canSpeak && (
-
speakOriginalWord(w.o, w.s)}
- className="mt-1.5 rounded-lg bg-sky-100 px-2 py-0.5 text-[11px] font-medium text-sky-700 hover:bg-sky-200 transition"
- title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>
- 🔊 Speak
-
- )}
-
- );
- })}
-
-
- )}
-
- );
- })}
-
- );
- })()}
- {/* Page navigation bar */}
-
- flipPage > 0 ? setFlipPage((p) => p - 1) : readerGoToPreviousChapter()}
- disabled={flipPage === 0 && readerChapter <= 1}
- className="rounded-xl border border-slate-200 bg-white px-4 py-1.5 text-sm font-semibold text-slate-600 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
- {flipPage === 0 ? '← Prev chapter' : '← Back'}
-
-
- {readerBook?.name} {readerChapter} · {flipPage + 1} / {flipTotalPages}
-
- flipPage < flipTotalPages - 1 ? setFlipPage((p) => p + 1) : readerGoToNextChapter()}
- disabled={flipPage === flipTotalPages - 1 && readerChapter >= readerTotalChapters}
- className="rounded-xl border border-slate-200 bg-white px-4 py-1.5 text-sm font-semibold text-slate-600 shadow-sm hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
- {flipPage === flipTotalPages - 1 ? 'Next chapter →' : 'Next →'}
-
-
-
- )}
-
- {/* Draw mode — Bible text on left, notebook canvas on right */}
+ {/* Draw mode: scripture beside a notebook canvas */}
{readerDrawMode && (
-
- {/* Scripture panel — sticky so it stays in view while notebook scrolls */}
-
+
-
{readerBook?.name} {readerChapter}
-
{readerTranslation}
-
Select text to highlight
+
{readerBook?.name} {readerChapter}
+
{readerTranslation}
+
Select text to highlight
{!readerLoading && readerVerses.map((v) => (
- {v.number}. {' '}
- {renderVerseText(v.number, v.text, '')}
+ {v.number}. {renderVerseText(v.number, v.text, '')}
))}
- {/* Notebook canvas */}
)}
-
+
- {/* Wide focus mode — fixed top bar to restore toolbar */}
- {readerWideLayout && !toolbarVisible && (
-
-
- {readerBook?.name} {readerChapter}
-
-
-
- ‹ Prev
-
- = readerTotalChapters}
- className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-40">
- Next ›
-
- setToolbarVisible(true)}
- className="rounded-lg border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50">
- ⚙ Tools
-
-
-
+ {chromeVisible && !readerDrawMode && (
+
= readerTotalChapters}
+ onAudio={() => openSheet('audio')} audioActive={readerAudioState.status === 'playing'}
+ onPen={() => setReaderDrawMode(true)}
+ onCrossRefs={() => { setReaderShowCrossRefs((v) => !v); if (!readerShowCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
+ crossRefsOn={readerShowCrossRefs}
+ onFocus={() => { setChromeVisible(false); setSheet(null); }}
+ primary={primary}
+ />
)}
- {/* Side chapter nav — fixed left/right arrows */}
- {!readerDrawMode && (
- <>
-
- ‹
-
- = readerTotalChapters}
- aria-label="Next chapter"
- className="fixed right-3 top-1/2 -translate-y-1/2 z-10 flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white/80 text-slate-500 shadow-md backdrop-blur-sm transition hover:bg-white hover:text-slate-900 disabled:opacity-0 disabled:pointer-events-none"
- >
- ›
-
- >
+ {/* Verse sheet: bottom sheet on phones, side pane on wide screens */}
+ {selectedVerse && !readerDrawMode && (
+ { if (!readerCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
+ bookmarkColor={readerBookmarks[selectedKey]}
+ onToggleBookmark={() => toggleReaderBookmark(selectedKey)}
+ onCycleColor={() => cycleBookmarkColor(selectedKey)}
+ onJump={jump}
+ onClose={() => setReaderSelectedVerse(null)}
+ />
)}
+
+
+
+
+ = readerTotalChapters}
+ />
+
{/* Highlight color picker — appears above text selection */}
{selectionPicker && (
80 ? selectionPicker.y - 56 : selectionPicker.y + 28,
}}
onPointerDown={(e) => e.stopPropagation()}
>
- {['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'].map(color => (
+ {HIGHLIGHT_COLORS.map((color) => (
{
e.preventDefault();
addTextHighlight({
- book: readerBookAbbrev,
- chapter: readerChapter,
- verse: selectionPicker.verse,
- startOffset: selectionPicker.startOffset,
- endOffset: selectionPicker.endOffset,
- color,
+ book: readerBookAbbrev, chapter: readerChapter, verse: selectionPicker.verse,
+ startOffset: selectionPicker.startOffset, endOffset: selectionPicker.endOffset, color,
});
window.getSelection()?.removeAllRanges();
setSelectionPicker(null);
@@ -988,12 +450,9 @@ export default function ReaderPage() {
/>
))}
{
- e.preventDefault();
- window.getSelection()?.removeAllRanges();
- setSelectionPicker(null);
- }}
+ aria-label="Cancel"
+ className="ml-1 flex h-7 w-7 items-center justify-center rounded-full bg-black/5 text-xs text-[var(--r-sub)] hover:bg-black/10"
+ onPointerDown={(e) => { e.preventDefault(); window.getSelection()?.removeAllRanges(); setSelectionPicker(null); }}
>
✕
diff --git a/src/pages/ReaderPage.test.jsx b/src/pages/ReaderPage.test.jsx
new file mode 100644
index 0000000..4903f86
--- /dev/null
+++ b/src/pages/ReaderPage.test.jsx
@@ -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( );
+ 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( );
+ fireEvent.click(screen.getByText(/This man came to Jesus/));
+ expect(ctx.setReaderSelectedVerse).toHaveBeenCalledWith(2);
+});